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.
Files changed (269) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -86
  3. data/.github/workflows/docs.yml +60 -0
  4. data/.github/workflows/release.yml +8 -8
  5. data/.gitignore +7 -0
  6. data/.npmrc +9 -0
  7. data/.standard.yml +4 -1
  8. data/Appraisals +33 -32
  9. data/CHANGELOG.md +41 -0
  10. data/README.md +13 -2
  11. data/UPGRADING.md +2 -68
  12. data/docs/concepts/backends-and-models.md +122 -0
  13. data/docs/concepts/endpoints.md +183 -0
  14. data/docs/concepts/links.md +212 -0
  15. data/docs/concepts/overview.md +80 -0
  16. data/docs/concepts/persisting.md +376 -0
  17. data/docs/concepts/relationships.md +527 -0
  18. data/docs/concepts/resources.md +677 -0
  19. data/docs/getting-started/first-api.md +289 -0
  20. data/docs/getting-started/installation.md +185 -0
  21. data/docs/intro.md +307 -0
  22. data/docs/js/authentication.md +63 -0
  23. data/docs/js/ddau.md +20 -0
  24. data/docs/js/extra-params.md +41 -0
  25. data/docs/js/index.md +112 -0
  26. data/docs/js/installation.md +120 -0
  27. data/docs/js/middleware.md +72 -0
  28. data/docs/js/models.md +202 -0
  29. data/docs/js/reads.md +494 -0
  30. data/docs/js/state-syncing.md +100 -0
  31. data/docs/js/writes.md +373 -0
  32. data/docs/reference/vandal.md +63 -0
  33. data/docs/reference/why.md +13 -0
  34. data/docs/topics/authorization.md +155 -0
  35. data/docs/topics/caching.md +55 -0
  36. data/docs/topics/customizing-sideloads.md +156 -0
  37. data/docs/topics/debugging.md +216 -0
  38. data/docs/topics/error-handling.md +210 -0
  39. data/docs/topics/etags.md +46 -0
  40. data/docs/topics/hopping-relationships.md +149 -0
  41. data/docs/topics/json-attributes.md +77 -0
  42. data/docs/topics/openstruct-models.md +50 -0
  43. data/docs/topics/remote-resources.md +291 -0
  44. data/docs/topics/testing.md +894 -0
  45. data/docs/topics/without-activerecord.md +324 -0
  46. data/docs/tutorial/index.md +58 -0
  47. data/docs/tutorial/step_0.md +107 -0
  48. data/docs/tutorial/step_1.md +199 -0
  49. data/docs/tutorial/step_2.md +312 -0
  50. data/docs/tutorial/step_3.md +142 -0
  51. data/docs/tutorial/step_4.md +135 -0
  52. data/docs/tutorial/step_5.md +69 -0
  53. data/docs/tutorial/step_6.md +82 -0
  54. data/docs/tutorial/step_7.md +205 -0
  55. data/docs/tutorial/step_8.md +128 -0
  56. data/docs/tutorial/step_9.md +171 -0
  57. data/docs/upgrading.md +265 -0
  58. data/gemfiles/rails_7_1.gemfile +4 -3
  59. data/gemfiles/{rails_7_2_graphiti_rails.gemfile → rails_7_2.gemfile} +3 -3
  60. data/gemfiles/{rails_8_1_graphiti_rails.gemfile → rails_8_0.gemfile} +3 -3
  61. data/gemfiles/{rails_8_0_graphiti_rails.gemfile → rails_8_1.gemfile} +3 -3
  62. data/graphiti.gemspec +7 -5
  63. data/{deprecated_generators → lib/generators}/graphiti/api_test_generator.rb +7 -1
  64. data/{deprecated_generators → lib/generators}/graphiti/generator_mixin.rb +14 -1
  65. data/{deprecated_generators → lib/generators}/graphiti/install_generator.rb +19 -13
  66. data/{deprecated_generators → lib/generators}/graphiti/resource_generator.rb +43 -6
  67. data/{deprecated_generators → lib/generators}/graphiti/templates/index_request_spec.rb.erb +1 -1
  68. data/{deprecated_generators → lib/generators}/graphiti/templates/resource_reads_spec.rb.erb +6 -6
  69. data/{deprecated_generators → lib/generators}/graphiti/templates/show_request_spec.rb.erb +1 -1
  70. data/lib/graphiti/configuration.rb +2 -2
  71. data/lib/graphiti/error_serializers/conflict_request.rb +19 -0
  72. data/lib/graphiti/error_serializers/deprecated_constants.rb +48 -0
  73. data/lib/graphiti/error_serializers/invalid_request.rb +56 -0
  74. data/lib/graphiti/error_serializers/validation.rb +143 -0
  75. data/lib/graphiti/errors.rb +4 -23
  76. data/lib/graphiti/query.rb +1 -1
  77. data/lib/graphiti/rails/context.rb +33 -0
  78. data/lib/graphiti/rails/controller.rb +41 -0
  79. data/lib/graphiti/rails/debugging.rb +18 -0
  80. data/lib/graphiti/rails/exception_handlers.rb +77 -0
  81. data/lib/graphiti/rails/railtie.rb +139 -0
  82. data/lib/graphiti/rails/responders.rb +21 -0
  83. data/lib/graphiti/rails/test_helpers.rb +22 -0
  84. data/lib/graphiti/rails.rb +47 -29
  85. data/lib/graphiti/resource/configuration.rb +1 -0
  86. data/lib/graphiti/resource/interface.rb +2 -2
  87. data/lib/graphiti/resource/persistence.rb +14 -2
  88. data/lib/graphiti/resource/remote.rb +2 -2
  89. data/lib/graphiti/resource/sideloading.rb +1 -1
  90. data/lib/graphiti/resource.rb +13 -1
  91. data/lib/graphiti/responders.rb +7 -20
  92. data/lib/graphiti/schema.rb +5 -1
  93. data/lib/graphiti/schema_diff.rb +4 -0
  94. data/lib/graphiti/scope.rb +45 -37
  95. data/lib/graphiti/serializer.rb +6 -0
  96. data/lib/graphiti/sideload/belongs_to.rb +38 -5
  97. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +27 -23
  98. data/lib/graphiti/sideload.rb +54 -35
  99. data/lib/graphiti/spec_helpers/errors.rb +73 -0
  100. data/lib/graphiti/spec_helpers/errors_proxy.rb +75 -0
  101. data/lib/graphiti/spec_helpers/helpers.rb +107 -0
  102. data/lib/graphiti/spec_helpers/node.rb +88 -0
  103. data/lib/graphiti/spec_helpers/rspec.rb +147 -0
  104. data/lib/graphiti/spec_helpers.rb +53 -0
  105. data/lib/graphiti/util/include_params.rb +2 -2
  106. data/lib/graphiti/util/persistence.rb +10 -11
  107. data/lib/graphiti/util/serializer_relationships.rb +41 -5
  108. data/lib/graphiti/version.rb +1 -1
  109. data/lib/graphiti-rails.rb +11 -0
  110. data/lib/graphiti.rb +34 -10
  111. data/lib/graphiti_errors.rb +11 -0
  112. data/lib/graphiti_spec_helpers/rspec.rb +3 -0
  113. data/lib/graphiti_spec_helpers.rb +11 -0
  114. data/lib/{graphiti/deprecated_tasks.rb → tasks/graphiti.rake} +6 -1
  115. data/package-lock.json +6199 -0
  116. data/package.json +5 -4
  117. data/website/.gitignore +20 -0
  118. data/website/README.md +43 -0
  119. data/website/docusaurus.config.js +141 -0
  120. data/website/package-lock.json +19474 -0
  121. data/website/package.json +46 -0
  122. data/website/sidebars.js +82 -0
  123. data/website/src/css/custom.css +58 -0
  124. data/website/src/pages/markdown-page.mdx +7 -0
  125. data/website/static/.nojekyll +0 -0
  126. data/website/static/1.13/2019/03/31/graphiti-1-0.html +205 -0
  127. data/website/static/1.13/2019/05/08/graphiti-1-1.html +212 -0
  128. data/website/static/1.13/2019/05/20/graphiti-1-2.html +214 -0
  129. data/website/static/1.13/2019/10/14/tutorial.html +198 -0
  130. data/website/static/1.13/CNAME +1 -0
  131. data/website/static/1.13/README.md +16 -0
  132. data/website/static/1.13/assets/css/syntax.css +60 -0
  133. data/website/static/1.13/assets/favicons/android-chrome-192x192.png +0 -0
  134. data/website/static/1.13/assets/favicons/android-chrome-256x256.png +0 -0
  135. data/website/static/1.13/assets/favicons/apple-touch-icon.png +0 -0
  136. data/website/static/1.13/assets/favicons/browserconfig.xml +9 -0
  137. data/website/static/1.13/assets/favicons/favicon-16x16.png +0 -0
  138. data/website/static/1.13/assets/favicons/favicon-32x32.png +0 -0
  139. data/website/static/1.13/assets/favicons/favicon.ico +0 -0
  140. data/website/static/1.13/assets/favicons/mstile-150x150.png +0 -0
  141. data/website/static/1.13/assets/favicons/safari-pinned-tab.svg +1 -0
  142. data/website/static/1.13/assets/favicons/site.webmanifest +19 -0
  143. data/website/static/1.13/assets/img/backend.gif +0 -0
  144. data/website/static/1.13/assets/img/conformity.png +0 -0
  145. data/website/static/1.13/assets/img/error_payload.png +0 -0
  146. data/website/static/1.13/assets/img/gh.png +0 -0
  147. data/website/static/1.13/assets/img/lifecycle.gif +0 -0
  148. data/website/static/1.13/assets/img/logo-500.png +0 -0
  149. data/website/static/1.13/assets/img/logo.png +0 -0
  150. data/website/static/1.13/assets/img/love-graffiti.jpg +0 -0
  151. data/website/static/1.13/assets/img/meta_total_count.png +0 -0
  152. data/website/static/1.13/assets/img/persist.jpg +0 -0
  153. data/website/static/1.13/assets/img/resource.gif +0 -0
  154. data/website/static/1.13/assets/img/rest-graffiti.jpg +0 -0
  155. data/website/static/1.13/assets/img/rest1.gif +0 -0
  156. data/website/static/1.13/assets/img/rest2.gif +0 -0
  157. data/website/static/1.13/assets/img/rest3.gif +0 -0
  158. data/website/static/1.13/assets/img/rethink-rest-graffiti.jpg +0 -0
  159. data/website/static/1.13/assets/img/why.png +0 -0
  160. data/website/static/1.13/assets/js/highlight.pack.js +2 -0
  161. data/website/static/1.13/assets/main.css +15518 -0
  162. data/website/static/1.13/assets/main.css.map +1 -0
  163. data/website/static/1.13/bin/bundle +109 -0
  164. data/website/static/1.13/bin/jekyll +27 -0
  165. data/website/static/1.13/bin/kramdown +27 -0
  166. data/website/static/1.13/bin/listen +27 -0
  167. data/website/static/1.13/bin/rake +27 -0
  168. data/website/static/1.13/bin/rougify +27 -0
  169. data/website/static/1.13/bin/safe_yaml +27 -0
  170. data/website/static/1.13/bin/sass +27 -0
  171. data/website/static/1.13/bin/sass-convert +27 -0
  172. data/website/static/1.13/bin/scss +27 -0
  173. data/website/static/1.13/blog.html +259 -0
  174. data/website/static/1.13/cheatsheet.html +316 -0
  175. data/website/static/1.13/cookbooks/authorization.md +0 -0
  176. data/website/static/1.13/cookbooks/caching.md +0 -0
  177. data/website/static/1.13/cookbooks/customizing-sideloads.html +325 -0
  178. data/website/static/1.13/cookbooks/etags.md +0 -0
  179. data/website/static/1.13/cookbooks/hopping-relationships.html +324 -0
  180. data/website/static/1.13/cookbooks/json_attributes.md +0 -0
  181. data/website/static/1.13/cookbooks/openstruct-models.md +0 -0
  182. data/website/static/1.13/cookbooks/remote-resources.md +0 -0
  183. data/website/static/1.13/cookbooks/without-activerecord.html +510 -0
  184. data/website/static/1.13/features.html +249 -0
  185. data/website/static/1.13/feed.xml +106 -0
  186. data/website/static/1.13/guides/concepts/backends-and-models.html +467 -0
  187. data/website/static/1.13/guides/concepts/debugging.html +440 -0
  188. data/website/static/1.13/guides/concepts/endpoints.html +432 -0
  189. data/website/static/1.13/guides/concepts/error-handling.html +396 -0
  190. data/website/static/1.13/guides/concepts/links.html +501 -0
  191. data/website/static/1.13/guides/concepts/remote-resources.html +536 -0
  192. data/website/static/1.13/guides/concepts/resources.html +2176 -0
  193. data/website/static/1.13/guides/concepts/testing.html +1469 -0
  194. data/website/static/1.13/guides/getting-started/installation.html +420 -0
  195. data/website/static/1.13/guides/graphiti-rails-migration.html +242 -0
  196. data/website/static/1.13/guides/index.html +269 -0
  197. data/website/static/1.13/guides/overview.html +325 -0
  198. data/website/static/1.13/guides/upgrading-2-0.html +193 -0
  199. data/website/static/1.13/guides/upgrading.html +314 -0
  200. data/website/static/1.13/guides/vandal.html +282 -0
  201. data/website/static/1.13/guides/why.html +1121 -0
  202. data/website/static/1.13/index.html +72 -0
  203. data/website/static/1.13/js/authentication.html +295 -0
  204. data/website/static/1.13/js/ddau.html +238 -0
  205. data/website/static/1.13/js/extra-params.html +270 -0
  206. data/website/static/1.13/js/index.html +321 -0
  207. data/website/static/1.13/js/installation.html +637 -0
  208. data/website/static/1.13/js/introduction.html +257 -0
  209. data/website/static/1.13/js/middleware.html +318 -0
  210. data/website/static/1.13/js/reads/fieldsets.html +271 -0
  211. data/website/static/1.13/js/reads/filtering.html +289 -0
  212. data/website/static/1.13/js/reads/includes.html +260 -0
  213. data/website/static/1.13/js/reads/index.html +497 -0
  214. data/website/static/1.13/js/reads/nested-queries.html +353 -0
  215. data/website/static/1.13/js/reads/pagination.html +260 -0
  216. data/website/static/1.13/js/reads/sorting.html +265 -0
  217. data/website/static/1.13/js/reads/statistics.html +289 -0
  218. data/website/static/1.13/js/state-syncing.html +340 -0
  219. data/website/static/1.13/js/writes/deferred.html +296 -0
  220. data/website/static/1.13/js/writes/dirty-tracking.html +399 -0
  221. data/website/static/1.13/js/writes/index.html +391 -0
  222. data/website/static/1.13/js/writes/nested.html +330 -0
  223. data/website/static/1.13/js/writes/validations.html +272 -0
  224. data/website/static/1.13/quickstart.html +660 -0
  225. data/website/static/1.13/template +161 -0
  226. data/website/static/1.13/tutorial/index.html +250 -0
  227. data/website/static/1.13/tutorial/step_0.html +292 -0
  228. data/website/static/1.13/tutorial/step_1.html +517 -0
  229. data/website/static/1.13/tutorial/step_2.html +481 -0
  230. data/website/static/1.13/tutorial/step_3.html +323 -0
  231. data/website/static/1.13/tutorial/step_4.html +318 -0
  232. data/website/static/1.13/tutorial/step_5.html +265 -0
  233. data/website/static/1.13/tutorial/step_6.html +276 -0
  234. data/website/static/1.13/tutorial/step_7.html +390 -0
  235. data/website/static/1.13/tutorial/step_8.html +316 -0
  236. data/website/static/1.13/tutorial/step_9.html +365 -0
  237. data/website/static/assets/img/error_payload.png +0 -0
  238. data/website/static/assets/img/legacy/legacy-0378a3bb39.png +0 -0
  239. data/website/static/assets/img/legacy/legacy-05bbd3e5fd.png +0 -0
  240. data/website/static/assets/img/legacy/legacy-07aa104495.png +0 -0
  241. data/website/static/assets/img/legacy/legacy-0c75a16b3a.gif +0 -0
  242. data/website/static/assets/img/legacy/legacy-3076df6209.png +0 -0
  243. data/website/static/assets/img/legacy/legacy-7f6889bc89.png +0 -0
  244. data/website/static/assets/img/legacy/legacy-a2cc4363c3.png +0 -0
  245. data/website/static/assets/img/legacy/legacy-f67cfa89ab.png +0 -0
  246. data/website/static/assets/img/meta_total_count.png +0 -0
  247. data/website/static/img/docusaurus-social-card.jpg +0 -0
  248. data/website/static/img/docusaurus.png +0 -0
  249. data/website/static/img/favicon.ico +0 -0
  250. data/website/static/img/logo.png +0 -0
  251. data/website/static/img/logo.svg +1 -0
  252. data/website/static/img/undraw_docusaurus_mountain.svg +171 -0
  253. data/website/static/img/undraw_docusaurus_react.svg +170 -0
  254. data/website/static/img/undraw_docusaurus_tree.svg +40 -0
  255. metadata +245 -46
  256. data/gemfiles/rails_6.gemfile +0 -18
  257. data/gemfiles/rails_6_graphiti_rails.gemfile +0 -19
  258. data/gemfiles/rails_7.gemfile +0 -18
  259. data/gemfiles/rails_7_1_graphiti_rails.gemfile +0 -19
  260. data/gemfiles/rails_7_graphiti_rails.gemfile +0 -19
  261. data/lib/graphiti/railtie.rb +0 -121
  262. /data/{deprecated_generators → lib/generators}/graphiti/resource_test_generator.rb +0 -0
  263. /data/{deprecated_generators → lib/generators}/graphiti/templates/application_resource.rb.erb +0 -0
  264. /data/{deprecated_generators → lib/generators}/graphiti/templates/controller.rb.erb +0 -0
  265. /data/{deprecated_generators → lib/generators}/graphiti/templates/create_request_spec.rb.erb +0 -0
  266. /data/{deprecated_generators → lib/generators}/graphiti/templates/destroy_request_spec.rb.erb +0 -0
  267. /data/{deprecated_generators → lib/generators}/graphiti/templates/resource.rb.erb +0 -0
  268. /data/{deprecated_generators → lib/generators}/graphiti/templates/resource_writes_spec.rb.erb +0 -0
  269. /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
+ ```