graphiti 2.0.0.beta.2 → 2.0.0.beta.4
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 +30 -86
- data/.github/workflows/docs.yml +60 -0
- data/.github/workflows/release.yml +8 -8
- data/.gitignore +7 -0
- data/.npmrc +9 -0
- data/.standard.yml +4 -1
- data/Appraisals +33 -32
- data/CHANGELOG.md +41 -0
- data/README.md +13 -2
- data/UPGRADING.md +2 -68
- data/docs/concepts/backends-and-models.md +122 -0
- data/docs/concepts/endpoints.md +183 -0
- data/docs/concepts/links.md +212 -0
- data/docs/concepts/overview.md +80 -0
- data/docs/concepts/persisting.md +376 -0
- data/docs/concepts/relationships.md +527 -0
- data/docs/concepts/resources.md +677 -0
- data/docs/getting-started/first-api.md +289 -0
- data/docs/getting-started/installation.md +185 -0
- data/docs/intro.md +307 -0
- data/docs/js/authentication.md +63 -0
- data/docs/js/ddau.md +20 -0
- data/docs/js/extra-params.md +41 -0
- data/docs/js/index.md +112 -0
- data/docs/js/installation.md +120 -0
- data/docs/js/middleware.md +72 -0
- data/docs/js/models.md +202 -0
- data/docs/js/reads.md +494 -0
- data/docs/js/state-syncing.md +100 -0
- data/docs/js/writes.md +373 -0
- data/docs/reference/vandal.md +63 -0
- data/docs/reference/why.md +13 -0
- data/docs/topics/authorization.md +155 -0
- data/docs/topics/caching.md +55 -0
- data/docs/topics/customizing-sideloads.md +156 -0
- data/docs/topics/debugging.md +216 -0
- data/docs/topics/error-handling.md +210 -0
- data/docs/topics/etags.md +46 -0
- data/docs/topics/hopping-relationships.md +149 -0
- data/docs/topics/json-attributes.md +77 -0
- data/docs/topics/openstruct-models.md +50 -0
- data/docs/topics/remote-resources.md +291 -0
- data/docs/topics/testing.md +894 -0
- data/docs/topics/without-activerecord.md +324 -0
- data/docs/tutorial/index.md +58 -0
- data/docs/tutorial/step_0.md +107 -0
- data/docs/tutorial/step_1.md +199 -0
- data/docs/tutorial/step_2.md +312 -0
- data/docs/tutorial/step_3.md +142 -0
- data/docs/tutorial/step_4.md +135 -0
- data/docs/tutorial/step_5.md +69 -0
- data/docs/tutorial/step_6.md +82 -0
- data/docs/tutorial/step_7.md +205 -0
- data/docs/tutorial/step_8.md +128 -0
- data/docs/tutorial/step_9.md +171 -0
- data/docs/upgrading.md +265 -0
- data/gemfiles/rails_7_1.gemfile +4 -3
- data/gemfiles/{rails_7_2_graphiti_rails.gemfile → rails_7_2.gemfile} +3 -3
- data/gemfiles/{rails_8_1_graphiti_rails.gemfile → rails_8_0.gemfile} +3 -3
- data/gemfiles/{rails_8_0_graphiti_rails.gemfile → rails_8_1.gemfile} +3 -3
- data/graphiti.gemspec +7 -5
- data/{deprecated_generators → lib/generators}/graphiti/api_test_generator.rb +7 -1
- data/{deprecated_generators → lib/generators}/graphiti/generator_mixin.rb +14 -1
- data/{deprecated_generators → lib/generators}/graphiti/install_generator.rb +19 -13
- data/{deprecated_generators → lib/generators}/graphiti/resource_generator.rb +43 -6
- data/{deprecated_generators → lib/generators}/graphiti/templates/index_request_spec.rb.erb +1 -1
- data/{deprecated_generators → lib/generators}/graphiti/templates/resource_reads_spec.rb.erb +6 -6
- data/{deprecated_generators → lib/generators}/graphiti/templates/show_request_spec.rb.erb +1 -1
- data/lib/graphiti/configuration.rb +2 -2
- data/lib/graphiti/error_serializers/conflict_request.rb +19 -0
- data/lib/graphiti/error_serializers/deprecated_constants.rb +48 -0
- data/lib/graphiti/error_serializers/invalid_request.rb +56 -0
- data/lib/graphiti/error_serializers/validation.rb +143 -0
- data/lib/graphiti/errors.rb +4 -23
- data/lib/graphiti/query.rb +1 -1
- data/lib/graphiti/rails/context.rb +33 -0
- data/lib/graphiti/rails/controller.rb +41 -0
- data/lib/graphiti/rails/debugging.rb +18 -0
- data/lib/graphiti/rails/exception_handlers.rb +77 -0
- data/lib/graphiti/rails/railtie.rb +139 -0
- data/lib/graphiti/rails/responders.rb +21 -0
- data/lib/graphiti/rails/test_helpers.rb +22 -0
- data/lib/graphiti/rails.rb +47 -29
- data/lib/graphiti/resource/configuration.rb +1 -0
- data/lib/graphiti/resource/interface.rb +2 -2
- data/lib/graphiti/resource/persistence.rb +14 -2
- data/lib/graphiti/resource/remote.rb +2 -2
- data/lib/graphiti/resource/sideloading.rb +1 -1
- data/lib/graphiti/resource.rb +13 -1
- data/lib/graphiti/responders.rb +7 -20
- data/lib/graphiti/schema.rb +5 -1
- data/lib/graphiti/schema_diff.rb +4 -0
- data/lib/graphiti/scope.rb +45 -37
- data/lib/graphiti/serializer.rb +6 -0
- data/lib/graphiti/sideload/belongs_to.rb +38 -5
- data/lib/graphiti/sideload/polymorphic_belongs_to.rb +27 -23
- data/lib/graphiti/sideload.rb +54 -35
- data/lib/graphiti/spec_helpers/errors.rb +73 -0
- data/lib/graphiti/spec_helpers/errors_proxy.rb +75 -0
- data/lib/graphiti/spec_helpers/helpers.rb +107 -0
- data/lib/graphiti/spec_helpers/node.rb +88 -0
- data/lib/graphiti/spec_helpers/rspec.rb +147 -0
- data/lib/graphiti/spec_helpers.rb +53 -0
- data/lib/graphiti/util/include_params.rb +2 -2
- data/lib/graphiti/util/persistence.rb +10 -11
- data/lib/graphiti/util/serializer_relationships.rb +41 -5
- data/lib/graphiti/version.rb +1 -1
- data/lib/graphiti-rails.rb +11 -0
- data/lib/graphiti.rb +34 -10
- data/lib/graphiti_errors.rb +11 -0
- data/lib/graphiti_spec_helpers/rspec.rb +3 -0
- data/lib/graphiti_spec_helpers.rb +11 -0
- data/lib/{graphiti/deprecated_tasks.rb → tasks/graphiti.rake} +6 -1
- data/package-lock.json +6199 -0
- data/package.json +5 -4
- data/website/.gitignore +20 -0
- data/website/README.md +43 -0
- data/website/docusaurus.config.js +141 -0
- data/website/package-lock.json +19474 -0
- data/website/package.json +46 -0
- data/website/sidebars.js +82 -0
- data/website/src/css/custom.css +58 -0
- data/website/src/pages/markdown-page.mdx +7 -0
- data/website/static/.nojekyll +0 -0
- data/website/static/1.13/2019/03/31/graphiti-1-0.html +205 -0
- data/website/static/1.13/2019/05/08/graphiti-1-1.html +212 -0
- data/website/static/1.13/2019/05/20/graphiti-1-2.html +214 -0
- data/website/static/1.13/2019/10/14/tutorial.html +198 -0
- data/website/static/1.13/CNAME +1 -0
- data/website/static/1.13/README.md +16 -0
- data/website/static/1.13/assets/css/syntax.css +60 -0
- 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 +9 -0
- 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 +1 -0
- data/website/static/1.13/assets/favicons/site.webmanifest +19 -0
- 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/gh.png +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/why.png +0 -0
- data/website/static/1.13/assets/js/highlight.pack.js +2 -0
- data/website/static/1.13/assets/main.css +15518 -0
- data/website/static/1.13/assets/main.css.map +1 -0
- data/website/static/1.13/bin/bundle +109 -0
- data/website/static/1.13/bin/jekyll +27 -0
- data/website/static/1.13/bin/kramdown +27 -0
- data/website/static/1.13/bin/listen +27 -0
- data/website/static/1.13/bin/rake +27 -0
- data/website/static/1.13/bin/rougify +27 -0
- data/website/static/1.13/bin/safe_yaml +27 -0
- data/website/static/1.13/bin/sass +27 -0
- data/website/static/1.13/bin/sass-convert +27 -0
- data/website/static/1.13/bin/scss +27 -0
- data/website/static/1.13/blog.html +259 -0
- data/website/static/1.13/cheatsheet.html +316 -0
- 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 +325 -0
- data/website/static/1.13/cookbooks/etags.md +0 -0
- data/website/static/1.13/cookbooks/hopping-relationships.html +324 -0
- 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 +510 -0
- data/website/static/1.13/features.html +249 -0
- data/website/static/1.13/feed.xml +106 -0
- data/website/static/1.13/guides/concepts/backends-and-models.html +467 -0
- data/website/static/1.13/guides/concepts/debugging.html +440 -0
- data/website/static/1.13/guides/concepts/endpoints.html +432 -0
- data/website/static/1.13/guides/concepts/error-handling.html +396 -0
- data/website/static/1.13/guides/concepts/links.html +501 -0
- data/website/static/1.13/guides/concepts/remote-resources.html +536 -0
- data/website/static/1.13/guides/concepts/resources.html +2176 -0
- data/website/static/1.13/guides/concepts/testing.html +1469 -0
- data/website/static/1.13/guides/getting-started/installation.html +420 -0
- data/website/static/1.13/guides/graphiti-rails-migration.html +242 -0
- data/website/static/1.13/guides/index.html +269 -0
- data/website/static/1.13/guides/overview.html +325 -0
- data/website/static/1.13/guides/upgrading-2-0.html +193 -0
- data/website/static/1.13/guides/upgrading.html +314 -0
- data/website/static/1.13/guides/vandal.html +282 -0
- data/website/static/1.13/guides/why.html +1121 -0
- data/website/static/1.13/index.html +72 -0
- data/website/static/1.13/js/authentication.html +295 -0
- data/website/static/1.13/js/ddau.html +238 -0
- data/website/static/1.13/js/extra-params.html +270 -0
- data/website/static/1.13/js/index.html +321 -0
- data/website/static/1.13/js/installation.html +637 -0
- data/website/static/1.13/js/introduction.html +257 -0
- data/website/static/1.13/js/middleware.html +318 -0
- data/website/static/1.13/js/reads/fieldsets.html +271 -0
- data/website/static/1.13/js/reads/filtering.html +289 -0
- data/website/static/1.13/js/reads/includes.html +260 -0
- data/website/static/1.13/js/reads/index.html +497 -0
- data/website/static/1.13/js/reads/nested-queries.html +353 -0
- data/website/static/1.13/js/reads/pagination.html +260 -0
- data/website/static/1.13/js/reads/sorting.html +265 -0
- data/website/static/1.13/js/reads/statistics.html +289 -0
- data/website/static/1.13/js/state-syncing.html +340 -0
- data/website/static/1.13/js/writes/deferred.html +296 -0
- data/website/static/1.13/js/writes/dirty-tracking.html +399 -0
- data/website/static/1.13/js/writes/index.html +391 -0
- data/website/static/1.13/js/writes/nested.html +330 -0
- data/website/static/1.13/js/writes/validations.html +272 -0
- data/website/static/1.13/quickstart.html +660 -0
- data/website/static/1.13/template +161 -0
- data/website/static/1.13/tutorial/index.html +250 -0
- data/website/static/1.13/tutorial/step_0.html +292 -0
- data/website/static/1.13/tutorial/step_1.html +517 -0
- data/website/static/1.13/tutorial/step_2.html +481 -0
- data/website/static/1.13/tutorial/step_3.html +323 -0
- data/website/static/1.13/tutorial/step_4.html +318 -0
- data/website/static/1.13/tutorial/step_5.html +265 -0
- data/website/static/1.13/tutorial/step_6.html +276 -0
- data/website/static/1.13/tutorial/step_7.html +390 -0
- data/website/static/1.13/tutorial/step_8.html +316 -0
- data/website/static/1.13/tutorial/step_9.html +365 -0
- 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 +1 -0
- data/website/static/img/undraw_docusaurus_mountain.svg +171 -0
- data/website/static/img/undraw_docusaurus_react.svg +170 -0
- data/website/static/img/undraw_docusaurus_tree.svg +40 -0
- metadata +245 -46
- data/gemfiles/rails_6.gemfile +0 -18
- data/gemfiles/rails_6_graphiti_rails.gemfile +0 -19
- data/gemfiles/rails_7.gemfile +0 -18
- data/gemfiles/rails_7_1_graphiti_rails.gemfile +0 -19
- data/gemfiles/rails_7_graphiti_rails.gemfile +0 -19
- data/lib/graphiti/railtie.rb +0 -121
- /data/{deprecated_generators → lib/generators}/graphiti/resource_test_generator.rb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/application_resource.rb.erb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/controller.rb.erb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/create_request_spec.rb.erb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/destroy_request_spec.rb.erb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/resource.rb.erb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/resource_writes_spec.rb.erb +0 -0
- /data/{deprecated_generators → lib/generators}/graphiti/templates/update_request_spec.rb.erb +0 -0
|
@@ -0,0 +1,527 @@
|
|
|
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
|
+
always_include_resource_ids: true # default: true for belongs_to, false otherwise
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`belongs_to` renders resource linkage by default, so a client can see which record a relationship points at without following the link:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
"employee": {
|
|
80
|
+
"data": { "type": "employees", "id": "1" },
|
|
81
|
+
"links": { "related": "/employees?filter[id]=1" }
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
That costs nothing, because the id is already on the parent as its foreign key. A `has_many` would have to run a query per record to answer the same question, so it stays off unless you ask for it.
|
|
86
|
+
|
|
87
|
+
A `belongs_to` falls back to loading the association when the foreign key cannot answer for it: a `params` block or `base_scope` can filter out the record the key points at, a polymorphic target takes its type from the record rather than the relationship, and a remote resource has no local key to read. Turning linkage on for those, or for a `has_many`, brings back the 1+N described in [#167](https://github.com/graphiti-api/graphiti/issues/167#issuecomment-686866646).
|
|
88
|
+
|
|
89
|
+
Set the default for every relationship on a resource, whatever its type:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
class ApplicationResource < Graphiti::Resource
|
|
93
|
+
self.always_include_resource_ids_by_default = false
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Subclasses inherit it, and a relationship passing `always_include_resource_ids` explicitly still wins.
|
|
98
|
+
|
|
99
|
+
### Conditional Relationships {#conditional-relationships}
|
|
100
|
+
|
|
101
|
+
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.
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
class EmployeeResource < ApplicationResource
|
|
105
|
+
has_many :salary_histories, readable: :admin?, writable: :admin?
|
|
106
|
+
|
|
107
|
+
def admin?
|
|
108
|
+
context.current_user.admin?
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
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.
|
|
114
|
+
|
|
115
|
+
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.
|
|
116
|
+
|
|
117
|
+
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:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
class SalaryHistoryResource < ApplicationResource
|
|
121
|
+
# Any resource declaring a relationship to SalaryHistoryResource with
|
|
122
|
+
# readable: :admin? will use this method, unless it defines its own.
|
|
123
|
+
def admin?
|
|
124
|
+
context.current_user.admin?
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
> **Upgrading to 1.12:** relationship guards are new enforcement, not a new
|
|
130
|
+
> option. Before 1.12, a symbol, string, or proc passed to a relationship's
|
|
131
|
+
> `readable`/`writable` was accepted and silently treated as `true`. The guard
|
|
132
|
+
> was never called. Those guards now run. If your app already passes one of
|
|
133
|
+
> these, a relationship that has been serialized all along may start
|
|
134
|
+
> disappearing from responses.
|
|
135
|
+
>
|
|
136
|
+
> To list every guarded relationship in your app before deploying, run
|
|
137
|
+
> `bin/rails runner 'puts Graphiti.guarded_relationships'`.
|
|
138
|
+
>
|
|
139
|
+
> Apps using `schema.json` also get this for free: guarded relationships are
|
|
140
|
+
> flagged in the schema, and the schema check reports them as
|
|
141
|
+
> `became guarded`.
|
|
142
|
+
|
|
143
|
+
### Customizing Scope {#customizing-scope}
|
|
144
|
+
|
|
145
|
+
Use `params` to change the query parameters that will be passed to the
|
|
146
|
+
associated Resource:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
has_many :active_positions, resource: PositionResource do
|
|
150
|
+
params do |hash, employees|
|
|
151
|
+
hash[:filter][:active] = true
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Would cause the underlying query:
|
|
156
|
+
#
|
|
157
|
+
# PositionResource.all({
|
|
158
|
+
# filter: {
|
|
159
|
+
# employee_id: array_of_employee_ids
|
|
160
|
+
# active: true
|
|
161
|
+
# }
|
|
162
|
+
# })
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
If there is no existing AR association for this we would also need to make it a getter/setter on the model.
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
# app/models/position.rb
|
|
169
|
+
attr_accessor :active_positions
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Customizing Assignment {#customizing-assignment}
|
|
173
|
+
|
|
174
|
+
Once we've fetched primary data and its relationship (e.g. we have an
|
|
175
|
+
`employees` array and `positions` array), we need to associate these
|
|
176
|
+
objects:
|
|
177
|
+
|
|
178
|
+
```ruby
|
|
179
|
+
employees.each do |e|
|
|
180
|
+
e.positions = positions.select { |p| p.employee_id == e.id }
|
|
181
|
+
end
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Occasionally this logic will be non-standard or more complex. Use
|
|
185
|
+
`assign_each` to customize, returning all relevant children for the
|
|
186
|
+
given parent:
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
has_many :positions do
|
|
190
|
+
assign_each do |employee, positions|
|
|
191
|
+
positions.select { |p| p.belongs_to?(employee) }
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Or if all else fails, use `#assign` to control all the logic:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
has_many :positions do
|
|
200
|
+
assign do |employees, positions|
|
|
201
|
+
employees.each do |employee|
|
|
202
|
+
positions.select { |p| p.belongs_to?(employee) }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Note**: ActiveRecord will sometimes cause unexpected queries when
|
|
209
|
+
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
|
|
210
|
+
care of this for you.
|
|
211
|
+
|
|
212
|
+
## has_many {#has-many}
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
has_many :positions
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Defaults to these common options:
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
has_many :positions,
|
|
222
|
+
foreign_key: :employee_id,
|
|
223
|
+
primary_key: :id,
|
|
224
|
+
always_include_resource_ids: false,
|
|
225
|
+
resource: PositionResource
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Which would cause the following query when sideloading:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
PositionResource.all({ filter: { employee_id => employee_ids } })
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
This means **we need to make sure that filter is supported**:
|
|
235
|
+
|
|
236
|
+
```ruby
|
|
237
|
+
class PositionResource < ApplicationResource
|
|
238
|
+
attribute :employee_id, :integer, only: [:filterable]
|
|
239
|
+
# ... code ...
|
|
240
|
+
end
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Once we've resolved `employees` and `positions` the resulting objects
|
|
244
|
+
would be associated with logic similar to:
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
employees.each do |e|
|
|
248
|
+
e.positions = positions.select { |p| p.employee_id == e.id }
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
And generate a Link:
|
|
253
|
+
|
|
254
|
+
`/positions?filter[employee_id]=1,2,3`
|
|
255
|
+
|
|
256
|
+
## belongs_to {#belongs-to}
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
belongs_to :employee
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Defaults to these common options:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
belongs_to :employee,
|
|
266
|
+
foreign_key: :employee_id,
|
|
267
|
+
primary_key: :id,
|
|
268
|
+
always_include_resource_ids: false,
|
|
269
|
+
resource: EmployeeResource
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
Which would cause the following query when sideloading:
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
EmployeeResource.all({ filter: { id => position_ids } })
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
And assign the resulting objects with logic similar to:
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
positions.each do |p|
|
|
282
|
+
p.employee = employees.find { |e| p.employee_id == e.id }
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
And generate a Link:
|
|
287
|
+
|
|
288
|
+
`/employees?filter[id]=1,2,3`
|
|
289
|
+
|
|
290
|
+
## has_one {#has-one}
|
|
291
|
+
|
|
292
|
+
`has_one` works exactly like `has_many`, but only one record will be
|
|
293
|
+
returned. When sideloading this will be a single element, much like
|
|
294
|
+
`belongs_to`.
|
|
295
|
+
|
|
296
|
+
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
|
|
297
|
+
an array*, and you should select the first record.
|
|
298
|
+
|
|
299
|
+
### Faux has_one {#faux-has-one}
|
|
300
|
+
|
|
301
|
+
A "Faux Has One" occurs when there is more than one record of
|
|
302
|
+
associated data, but we only want to return the *first* record in that
|
|
303
|
+
array. Consider this `ActiveRecord` relationship:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
# app/models/employee.rb
|
|
307
|
+
has_many :positions
|
|
308
|
+
has_one :current_position, -> { where(created_at: :desc) }, class_name: 'Position'
|
|
309
|
+
|
|
310
|
+
Employee.includes('current_position').to_a
|
|
311
|
+
|
|
312
|
+
# SELECT * FROM employees
|
|
313
|
+
# SELECT * FROM positions WHERE employee_id IN (?) ORDER BY created_at DESC
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
When we eager load, *more than one Position is returned from the
|
|
317
|
+
database query*. Assigning only the first record and dropping the rest
|
|
318
|
+
occurs in ruby, not the database query.
|
|
319
|
+
|
|
320
|
+
The same thing happens in Graphiti:
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
# app/resources/employee_resource.rb
|
|
324
|
+
has_many :positions
|
|
325
|
+
has_one :current_position, resource: PositionResource do
|
|
326
|
+
params do |hash|
|
|
327
|
+
hash[:sort] = '-created_at'
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
EmployeeResource.all(include: 'current_position')
|
|
332
|
+
# PositionResource.all({
|
|
333
|
+
# filter: { employee_id: employee_ids },
|
|
334
|
+
# sort: '-created_at'
|
|
335
|
+
# })
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Though everything works as expected, a large number of Position records
|
|
339
|
+
can incur a performance penalty (as we'd be instantiating a large number
|
|
340
|
+
of ActiveRecord objects).
|
|
341
|
+
|
|
342
|
+
For this reason, you are encouraged to model Faux Has One's in such a
|
|
343
|
+
way that the underlying database query only returns the relevant single
|
|
344
|
+
record. Imagine if we had a `historical_index` column on `positions`, where a value of `1` meant "most recent":
|
|
345
|
+
|
|
346
|
+
```ruby
|
|
347
|
+
# app/models/employee.rb
|
|
348
|
+
has_many :positions
|
|
349
|
+
has_one :current_position, -> { where(historical_index: 1) }, class_name: 'Position'
|
|
350
|
+
|
|
351
|
+
Employee.includes('current_position').to_a
|
|
352
|
+
|
|
353
|
+
# SELECT * FROM employees
|
|
354
|
+
# SELECT * FROM positions WHERE employee_id IN (?) AND historical_index = 1
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
We've ensured the *query itself* only returns a single record.
|
|
358
|
+
Optimizing a Graphiti API is the same as optimizing queries.
|
|
359
|
+
|
|
360
|
+
## many_to_many {#many-to-many}
|
|
361
|
+
|
|
362
|
+
> This relationship is specific to relational databases that use a "join
|
|
363
|
+
> table" between two tables.
|
|
364
|
+
|
|
365
|
+
Though you can make this work for other ORMs/clients, it's easiest to
|
|
366
|
+
explain by focusing on `ActiveRecord`.
|
|
367
|
+
|
|
368
|
+
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**:
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
class Employee < ApplicationRecord
|
|
372
|
+
has_many :team_memberships
|
|
373
|
+
has_many :teams, through: :team_memberships
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
class TeamMembership < ApplicationRecord
|
|
377
|
+
belongs_to :employee
|
|
378
|
+
belongs_to :team
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
class Team < ApplicationRecord
|
|
382
|
+
has_many :team_memberships
|
|
383
|
+
has_many :employees, through: :team_memberships
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
You can always expose `team_memberships` to your API - particularly
|
|
388
|
+
useful if that table holds metadata about the relationship.
|
|
389
|
+
|
|
390
|
+
Other times, however, clients of the API should not have knowledge of
|
|
391
|
+
this implementation detail. In these cases, use `many_to_many`:
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
class EmployeeResource < ApplicationResource
|
|
395
|
+
many_to_many :teams
|
|
396
|
+
end
|
|
397
|
+
# Generates the Link
|
|
398
|
+
# /teams?filter[employee_id]=1,2,3
|
|
399
|
+
|
|
400
|
+
class TeamResource < ApplicationResource
|
|
401
|
+
many_to_many :employees
|
|
402
|
+
end
|
|
403
|
+
# Generates the Link
|
|
404
|
+
# /teams?filter[team_id]=1,2,3
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
The `many_to_many` call will automatically add a Filter to the associated resource. The logic for that filter, in the case of `ActiveRecord`:
|
|
408
|
+
|
|
409
|
+
```ruby
|
|
410
|
+
# app/resources/employee_resource.rb
|
|
411
|
+
|
|
412
|
+
filter :team_id, :integer do
|
|
413
|
+
eq do |scope, value|
|
|
414
|
+
scope
|
|
415
|
+
.includes(:team_memberships)
|
|
416
|
+
.where(team_memberships: { team_id: value }
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
To customize the foreign key, you will need to specify a hash rather
|
|
422
|
+
than a symbol. The hash key is the relationship name, so the above is
|
|
423
|
+
equivalent to
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
# app/resources/employee_resource.rb
|
|
427
|
+
|
|
428
|
+
many_to_many :teams, foreign_key: { team_memberships: :team_id }
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
If using ActiveRecord, and the API relationship name does not match your
|
|
432
|
+
Model relationship name, use `:as` to specify the model relationship
|
|
433
|
+
that should be used to derive the query:
|
|
434
|
+
|
|
435
|
+
```ruby
|
|
436
|
+
# The API relationship is "teams", ActiveRecord has "groups"
|
|
437
|
+
many_to_many :teams, as: :groups
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
## polymorphic_belongs_to {#polymorphic-belongs-to}
|
|
441
|
+
|
|
442
|
+
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.
|
|
443
|
+
|
|
444
|
+
Given the following [polymorphic ActiveRecords](https://guides.rubyonrails.org/association_basics.html#polymorphic-associations):
|
|
445
|
+
|
|
446
|
+
```ruby
|
|
447
|
+
class Note < ApplicationRecord
|
|
448
|
+
belongs_to :notable, polymorphic: true
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
class Employee < ApplicationRecord
|
|
452
|
+
has_many :notes, as: :notable
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
class Department < ApplicationRecord
|
|
456
|
+
has_many :notes, as: :notable
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
class Team < ApplicationRecord
|
|
460
|
+
has_many :notes, as: :notable
|
|
461
|
+
end
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
By `ActiveRecord` convention, the `notes` table would have columns `notable_id` and `notable_type`.
|
|
465
|
+
|
|
466
|
+
Graphiti has the same concept. In this case we would group all the notes
|
|
467
|
+
by a given `notable_type`, and follow a different `belongs_to`
|
|
468
|
+
association for each group:
|
|
469
|
+
|
|
470
|
+
```ruby
|
|
471
|
+
# app/resources/note_resource.rb
|
|
472
|
+
polymorphic_belongs_to :notable do
|
|
473
|
+
group_by(:notable_type) do
|
|
474
|
+
on(:Employee)
|
|
475
|
+
on(:Department)
|
|
476
|
+
on(:Team)
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
The `on` DSL is shorthand for a `belongs_to` relationship that accepts
|
|
482
|
+
all the usual options and customizations:
|
|
483
|
+
|
|
484
|
+
```ruby
|
|
485
|
+
on(:Employee).belongs_to :employee,
|
|
486
|
+
resource: EmployeeResource
|
|
487
|
+
# ... etc ...
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
In other words: group all Notes by `notable_type`, and for all that have the value of `"Employee"` use the `belongs_to :employee` relationship
|
|
491
|
+
for further querying.
|
|
492
|
+
|
|
493
|
+
## polymorphic_has_many {#polymorphic-has-many}
|
|
494
|
+
|
|
495
|
+
Continuing from the prior section, the corresponding association of a
|
|
496
|
+
`polymorphic_belongs_to` is a `polymorphic_has_many`:
|
|
497
|
+
|
|
498
|
+
```ruby
|
|
499
|
+
class EmployeeResource < ApplicationResource
|
|
500
|
+
polymorphic_has_many :notes, as: :notable
|
|
501
|
+
end
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Predictably, this causes the query:
|
|
505
|
+
|
|
506
|
+
```ruby
|
|
507
|
+
NoteResource.all({
|
|
508
|
+
filter: {
|
|
509
|
+
notable_type: 'Employee',
|
|
510
|
+
notable_id: employee_ids
|
|
511
|
+
}
|
|
512
|
+
})
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
And the Link
|
|
516
|
+
|
|
517
|
+
`/notes?filter[notable_id]=1,2,3&filter[notable_type]=Employee`
|
|
518
|
+
|
|
519
|
+
Which means the following filters are required:
|
|
520
|
+
|
|
521
|
+
```ruby
|
|
522
|
+
class NoteResource < ApplicationResource
|
|
523
|
+
attribute :notable_id, :integer, only: [:filterable]
|
|
524
|
+
attribute :notable_type, :string, only: [:filterable]
|
|
525
|
+
# ... code ...
|
|
526
|
+
end
|
|
527
|
+
```
|