graphiti 2.0.0.beta.3 → 2.0.0.beta.5

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -86
  3. data/.github/workflows/docs.yml +57 -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 +48 -0
  10. data/README.md +13 -2
  11. data/UPGRADING.md +2 -58
  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 +260 -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 -23
  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 +68 -7
  67. data/{deprecated_generators → lib/generators}/graphiti/templates/controller.rb.erb +1 -1
  68. data/{deprecated_generators → lib/generators}/graphiti/templates/index_request_spec.rb.erb +1 -1
  69. data/{deprecated_generators → lib/generators}/graphiti/templates/resource_reads_spec.rb.erb +6 -6
  70. data/{deprecated_generators → lib/generators}/graphiti/templates/show_request_spec.rb.erb +1 -1
  71. data/lib/graphiti/configuration.rb +2 -2
  72. data/lib/graphiti/error_serializers/conflict_request.rb +19 -0
  73. data/lib/graphiti/error_serializers/deprecated_constants.rb +48 -0
  74. data/lib/graphiti/error_serializers/invalid_request.rb +56 -0
  75. data/lib/graphiti/error_serializers/validation.rb +143 -0
  76. data/lib/graphiti/errors.rb +4 -4
  77. data/lib/graphiti/query.rb +1 -1
  78. data/lib/graphiti/rails/context.rb +33 -0
  79. data/lib/graphiti/rails/controller.rb +41 -0
  80. data/lib/graphiti/rails/debugging.rb +18 -0
  81. data/lib/graphiti/rails/exception_handlers.rb +77 -0
  82. data/lib/graphiti/rails/railtie.rb +139 -0
  83. data/lib/graphiti/rails/rake_helpers.rb +42 -0
  84. data/lib/graphiti/rails/responders.rb +21 -0
  85. data/lib/graphiti/rails/test_helpers.rb +22 -0
  86. data/lib/graphiti/rails.rb +47 -29
  87. data/lib/graphiti/resource/configuration.rb +1 -0
  88. data/lib/graphiti/resource/interface.rb +2 -2
  89. data/lib/graphiti/resource/remote.rb +2 -2
  90. data/lib/graphiti/resource/sideloading.rb +1 -1
  91. data/lib/graphiti/resource.rb +13 -1
  92. data/lib/graphiti/responders.rb +7 -20
  93. data/lib/graphiti/schema.rb +5 -1
  94. data/lib/graphiti/schema_diff.rb +4 -0
  95. data/lib/graphiti/scope.rb +45 -37
  96. data/lib/graphiti/serializer.rb +6 -0
  97. data/lib/graphiti/sideload/belongs_to.rb +38 -5
  98. data/lib/graphiti/sideload/polymorphic_belongs_to.rb +27 -23
  99. data/lib/graphiti/sideload.rb +54 -35
  100. data/lib/graphiti/spec_helpers/errors.rb +73 -0
  101. data/lib/graphiti/spec_helpers/errors_proxy.rb +75 -0
  102. data/lib/graphiti/spec_helpers/helpers.rb +107 -0
  103. data/lib/graphiti/spec_helpers/matchers.rb +196 -0
  104. data/lib/graphiti/spec_helpers/node.rb +88 -0
  105. data/lib/graphiti/spec_helpers/rspec.rb +148 -0
  106. data/lib/graphiti/spec_helpers.rb +54 -0
  107. data/lib/graphiti/util/include_params.rb +2 -2
  108. data/lib/graphiti/util/serializer_relationships.rb +41 -5
  109. data/lib/graphiti/version.rb +1 -1
  110. data/lib/graphiti-rails.rb +11 -0
  111. data/lib/graphiti.rb +34 -10
  112. data/lib/graphiti_errors.rb +11 -0
  113. data/lib/graphiti_spec_helpers/rspec.rb +3 -0
  114. data/lib/graphiti_spec_helpers.rb +11 -0
  115. data/lib/tasks/graphiti.rake +29 -0
  116. data/package-lock.json +6199 -0
  117. data/package.json +5 -4
  118. data/website/.gitignore +20 -0
  119. data/website/README.md +43 -0
  120. data/website/docusaurus.config.js +197 -0
  121. data/website/package-lock.json +19474 -0
  122. data/website/package.json +46 -0
  123. data/website/sidebars.js +82 -0
  124. data/website/src/css/custom.css +85 -0
  125. data/website/src/pages/markdown-page.mdx +7 -0
  126. data/website/static/.nojekyll +0 -0
  127. data/website/static/1.13/2019/03/31/graphiti-1-0.html +205 -0
  128. data/website/static/1.13/2019/05/08/graphiti-1-1.html +212 -0
  129. data/website/static/1.13/2019/05/20/graphiti-1-2.html +214 -0
  130. data/website/static/1.13/2019/10/14/tutorial.html +198 -0
  131. data/website/static/1.13/CNAME +1 -0
  132. data/website/static/1.13/README.md +16 -0
  133. data/website/static/1.13/assets/css/syntax.css +60 -0
  134. data/website/static/1.13/assets/favicons/android-chrome-192x192.png +0 -0
  135. data/website/static/1.13/assets/favicons/android-chrome-256x256.png +0 -0
  136. data/website/static/1.13/assets/favicons/apple-touch-icon.png +0 -0
  137. data/website/static/1.13/assets/favicons/browserconfig.xml +9 -0
  138. data/website/static/1.13/assets/favicons/favicon-16x16.png +0 -0
  139. data/website/static/1.13/assets/favicons/favicon-32x32.png +0 -0
  140. data/website/static/1.13/assets/favicons/favicon.ico +0 -0
  141. data/website/static/1.13/assets/favicons/mstile-150x150.png +0 -0
  142. data/website/static/1.13/assets/favicons/safari-pinned-tab.svg +1 -0
  143. data/website/static/1.13/assets/favicons/site.webmanifest +19 -0
  144. data/website/static/1.13/assets/img/backend.gif +0 -0
  145. data/website/static/1.13/assets/img/conformity.png +0 -0
  146. data/website/static/1.13/assets/img/error_payload.png +0 -0
  147. data/website/static/1.13/assets/img/fancy-cushion.png +0 -0
  148. data/website/static/1.13/assets/img/gh.png +0 -0
  149. data/website/static/1.13/assets/img/home-bg.jpg +0 -0
  150. data/website/static/1.13/assets/img/lifecycle.gif +0 -0
  151. data/website/static/1.13/assets/img/logo-500.png +0 -0
  152. data/website/static/1.13/assets/img/logo.png +0 -0
  153. data/website/static/1.13/assets/img/love-graffiti.jpg +0 -0
  154. data/website/static/1.13/assets/img/meta_total_count.png +0 -0
  155. data/website/static/1.13/assets/img/persist.jpg +0 -0
  156. data/website/static/1.13/assets/img/resource.gif +0 -0
  157. data/website/static/1.13/assets/img/rest-graffiti.jpg +0 -0
  158. data/website/static/1.13/assets/img/rest1.gif +0 -0
  159. data/website/static/1.13/assets/img/rest2.gif +0 -0
  160. data/website/static/1.13/assets/img/rest3.gif +0 -0
  161. data/website/static/1.13/assets/img/rethink-rest-graffiti.jpg +0 -0
  162. data/website/static/1.13/assets/img/sunrise.png +0 -0
  163. data/website/static/1.13/assets/img/why.png +0 -0
  164. data/website/static/1.13/assets/js/highlight.pack.js +2 -0
  165. data/website/static/1.13/assets/main.css +15518 -0
  166. data/website/static/1.13/assets/main.css.map +1 -0
  167. data/website/static/1.13/bin/bundle +109 -0
  168. data/website/static/1.13/bin/jekyll +27 -0
  169. data/website/static/1.13/bin/kramdown +27 -0
  170. data/website/static/1.13/bin/listen +27 -0
  171. data/website/static/1.13/bin/rake +27 -0
  172. data/website/static/1.13/bin/rougify +27 -0
  173. data/website/static/1.13/bin/safe_yaml +27 -0
  174. data/website/static/1.13/bin/sass +27 -0
  175. data/website/static/1.13/bin/sass-convert +27 -0
  176. data/website/static/1.13/bin/scss +27 -0
  177. data/website/static/1.13/blog.html +259 -0
  178. data/website/static/1.13/cheatsheet.html +316 -0
  179. data/website/static/1.13/cookbooks/authorization.md +0 -0
  180. data/website/static/1.13/cookbooks/caching.md +0 -0
  181. data/website/static/1.13/cookbooks/customizing-sideloads.html +325 -0
  182. data/website/static/1.13/cookbooks/etags.md +0 -0
  183. data/website/static/1.13/cookbooks/hopping-relationships.html +324 -0
  184. data/website/static/1.13/cookbooks/json_attributes.md +0 -0
  185. data/website/static/1.13/cookbooks/openstruct-models.md +0 -0
  186. data/website/static/1.13/cookbooks/remote-resources.md +0 -0
  187. data/website/static/1.13/cookbooks/without-activerecord.html +510 -0
  188. data/website/static/1.13/features.html +249 -0
  189. data/website/static/1.13/feed.xml +106 -0
  190. data/website/static/1.13/guides/concepts/backends-and-models.html +467 -0
  191. data/website/static/1.13/guides/concepts/debugging.html +440 -0
  192. data/website/static/1.13/guides/concepts/endpoints.html +432 -0
  193. data/website/static/1.13/guides/concepts/error-handling.html +396 -0
  194. data/website/static/1.13/guides/concepts/links.html +501 -0
  195. data/website/static/1.13/guides/concepts/remote-resources.html +536 -0
  196. data/website/static/1.13/guides/concepts/resources.html +2176 -0
  197. data/website/static/1.13/guides/concepts/testing.html +1469 -0
  198. data/website/static/1.13/guides/getting-started/installation.html +420 -0
  199. data/website/static/1.13/guides/graphiti-rails-migration.html +242 -0
  200. data/website/static/1.13/guides/index.html +269 -0
  201. data/website/static/1.13/guides/overview.html +325 -0
  202. data/website/static/1.13/guides/upgrading-2-0.html +193 -0
  203. data/website/static/1.13/guides/upgrading.html +314 -0
  204. data/website/static/1.13/guides/vandal.html +282 -0
  205. data/website/static/1.13/guides/why.html +1121 -0
  206. data/website/static/1.13/index.html +72 -0
  207. data/website/static/1.13/js/authentication.html +295 -0
  208. data/website/static/1.13/js/ddau.html +238 -0
  209. data/website/static/1.13/js/extra-params.html +270 -0
  210. data/website/static/1.13/js/index.html +321 -0
  211. data/website/static/1.13/js/installation.html +637 -0
  212. data/website/static/1.13/js/introduction.html +257 -0
  213. data/website/static/1.13/js/middleware.html +318 -0
  214. data/website/static/1.13/js/reads/fieldsets.html +271 -0
  215. data/website/static/1.13/js/reads/filtering.html +289 -0
  216. data/website/static/1.13/js/reads/includes.html +260 -0
  217. data/website/static/1.13/js/reads/index.html +497 -0
  218. data/website/static/1.13/js/reads/nested-queries.html +353 -0
  219. data/website/static/1.13/js/reads/pagination.html +260 -0
  220. data/website/static/1.13/js/reads/sorting.html +265 -0
  221. data/website/static/1.13/js/reads/statistics.html +289 -0
  222. data/website/static/1.13/js/state-syncing.html +340 -0
  223. data/website/static/1.13/js/writes/deferred.html +296 -0
  224. data/website/static/1.13/js/writes/dirty-tracking.html +399 -0
  225. data/website/static/1.13/js/writes/index.html +391 -0
  226. data/website/static/1.13/js/writes/nested.html +330 -0
  227. data/website/static/1.13/js/writes/validations.html +272 -0
  228. data/website/static/1.13/quickstart.html +660 -0
  229. data/website/static/1.13/template +161 -0
  230. data/website/static/1.13/tutorial/index.html +250 -0
  231. data/website/static/1.13/tutorial/step_0.html +292 -0
  232. data/website/static/1.13/tutorial/step_1.html +517 -0
  233. data/website/static/1.13/tutorial/step_2.html +481 -0
  234. data/website/static/1.13/tutorial/step_3.html +323 -0
  235. data/website/static/1.13/tutorial/step_4.html +318 -0
  236. data/website/static/1.13/tutorial/step_5.html +265 -0
  237. data/website/static/1.13/tutorial/step_6.html +276 -0
  238. data/website/static/1.13/tutorial/step_7.html +390 -0
  239. data/website/static/1.13/tutorial/step_8.html +316 -0
  240. data/website/static/1.13/tutorial/step_9.html +365 -0
  241. data/website/static/CNAME +1 -0
  242. data/website/static/assets/img/error_payload.png +0 -0
  243. data/website/static/assets/img/legacy/legacy-0378a3bb39.png +0 -0
  244. data/website/static/assets/img/legacy/legacy-05bbd3e5fd.png +0 -0
  245. data/website/static/assets/img/legacy/legacy-07aa104495.png +0 -0
  246. data/website/static/assets/img/legacy/legacy-0c75a16b3a.gif +0 -0
  247. data/website/static/assets/img/legacy/legacy-3076df6209.png +0 -0
  248. data/website/static/assets/img/legacy/legacy-7f6889bc89.png +0 -0
  249. data/website/static/assets/img/legacy/legacy-a2cc4363c3.png +0 -0
  250. data/website/static/assets/img/legacy/legacy-f67cfa89ab.png +0 -0
  251. data/website/static/assets/img/meta_total_count.png +0 -0
  252. data/website/static/img/docusaurus-social-card.jpg +0 -0
  253. data/website/static/img/docusaurus.png +0 -0
  254. data/website/static/img/favicon.ico +0 -0
  255. data/website/static/img/logo.png +0 -0
  256. data/website/static/img/logo.svg +1 -0
  257. data/website/static/img/undraw_docusaurus_mountain.svg +171 -0
  258. data/website/static/img/undraw_docusaurus_react.svg +170 -0
  259. data/website/static/img/undraw_docusaurus_tree.svg +40 -0
  260. metadata +251 -54
  261. data/gemfiles/rails_6.gemfile +0 -18
  262. data/gemfiles/rails_6_graphiti_rails.gemfile +0 -19
  263. data/gemfiles/rails_7.gemfile +0 -18
  264. data/gemfiles/rails_7_1_graphiti_rails.gemfile +0 -19
  265. data/gemfiles/rails_7_graphiti_rails.gemfile +0 -19
  266. data/lib/graphiti/deprecated_tasks.rb +0 -49
  267. data/lib/graphiti/railtie.rb +0 -121
  268. /data/{deprecated_generators → lib/generators}/graphiti/resource_test_generator.rb +0 -0
  269. /data/{deprecated_generators → lib/generators}/graphiti/templates/application_resource.rb.erb +0 -0
  270. /data/{deprecated_generators → lib/generators}/graphiti/templates/create_request_spec.rb.erb +0 -0
  271. /data/{deprecated_generators → lib/generators}/graphiti/templates/destroy_request_spec.rb.erb +0 -0
  272. /data/{deprecated_generators → lib/generators}/graphiti/templates/resource.rb.erb +0 -0
  273. /data/{deprecated_generators → lib/generators}/graphiti/templates/resource_writes_spec.rb.erb +0 -0
  274. /data/{deprecated_generators → lib/generators}/graphiti/templates/update_request_spec.rb.erb +0 -0
@@ -108,40 +108,44 @@ class Graphiti::Sideload::PolymorphicBelongsTo < Graphiti::Sideload::BelongsTo
108
108
  end
109
109
 
110
110
  def resolve(parents, query, graph_parent)
111
- return future_resolve(parents, query, graph_parent).value! if Graphiti.config.concurrency
111
+ if Graphiti.config.concurrency
112
+ future_resolve(parents, query, graph_parent).value!
113
+ else
114
+ sync_resolve(parents, query, graph_parent)
115
+ end
116
+ end
112
117
 
113
- parents.group_by(&grouper.field_name).each_pair do |group_name, group|
114
- next if group_name.nil? || grouper.ignore?(group_name)
118
+ def future_resolve(parents, query, graph_parent)
119
+ promises = []
120
+ each_resolvable_group(parents, query) do |child, group, child_query|
121
+ promises << child.future_resolve(group, child_query, graph_parent)
122
+ end
123
+ Concurrent::Promises.zip(*promises)
124
+ end
115
125
 
116
- match = ->(c) { c.group_name == group_name.to_sym }
117
- if (sideload = children.values.find(&match))
118
- duped = remove_invalid_sideloads(sideload.resource, query)
119
- sideload.resolve(group, duped, graph_parent)
120
- else
121
- err = ::Graphiti::Errors::PolymorphicSideloadChildNotFound
122
- raise err.new(self, group_name)
123
- end
126
+ private
127
+
128
+ def sync_resolve(parents, query, graph_parent)
129
+ each_resolvable_group(parents, query) do |child, group, child_query|
130
+ child.resolve(group, child_query, graph_parent)
124
131
  end
125
132
  end
126
133
 
127
- def future_resolve(parents, query, graph_parent)
128
- promises = parents.group_by(&grouper.field_name).filter_map do |(group_name, group)|
134
+ # Group parents by their polymorphic type and yield each group's child
135
+ # sideload alongside a query pruned to the sideloads that child supports.
136
+ def each_resolvable_group(parents, query)
137
+ parents.group_by(&grouper.field_name).each_pair do |group_name, group|
129
138
  next if group_name.nil? || grouper.ignore?(group_name)
130
139
 
131
- match = ->(c) { c.group_name == group_name.to_sym }
132
- if (sideload = children.values.find(&match))
133
- duped = remove_invalid_sideloads(sideload.resource, query)
134
- sideload.future_resolve(group, duped, graph_parent)
135
- else
136
- err = ::Graphiti::Errors::PolymorphicSideloadChildNotFound
137
- raise err.new(self, group_name)
140
+ child = children.values.find { |candidate| candidate.group_name == group_name.to_sym }
141
+ unless child
142
+ raise ::Graphiti::Errors::PolymorphicSideloadChildNotFound.new(self, group_name)
138
143
  end
144
+
145
+ yield child, group, remove_invalid_sideloads(child.resource, query)
139
146
  end
140
- Concurrent::Promises.zip(*promises)
141
147
  end
142
148
 
143
- private
144
-
145
149
  # We may be requesting a relationship that some subclasses support,
146
150
  # but not others. Remove anything we don't support.
147
151
  # TODO: spec to ensure this dupe logic doesn't mutate the original
@@ -122,8 +122,25 @@ module Graphiti
122
122
  !!@polymorphic_as
123
123
  end
124
124
 
125
+ # False everywhere but a plain belongs_to - see
126
+ # Sideload::BelongsTo#linkage_from_foreign_key?.
127
+ def linkage_from_foreign_key?
128
+ false
129
+ end
130
+
131
+ # nil at either of the first two levels means "not specified" rather
132
+ # than "false".
125
133
  def always_include_resource_ids?
126
- !!@always_include_resource_ids
134
+ return !!@always_include_resource_ids unless @always_include_resource_ids.nil?
135
+
136
+ configured = parent_resource_class&.always_include_resource_ids_by_default
137
+ return !!configured unless configured.nil?
138
+
139
+ default_include_resource_ids?
140
+ end
141
+
142
+ def default_include_resource_ids?
143
+ false
127
144
  end
128
145
 
129
146
  def link?
@@ -292,48 +309,19 @@ module Graphiti
292
309
  children.replace(associated) if track_associated
293
310
  end
294
311
 
295
- # Synchronous counterpart to #future_resolve, used when concurrency is off.
296
- # Mirrors #future_resolve but resolves inline via the synchronous
297
- # Scope#resolve / #load paths (no promises). See Scope#sync_resolve_sideloads.
298
312
  def resolve(parents, query, graph_parent)
299
- return future_resolve(parents, query, graph_parent).value! if Graphiti.config.concurrency
300
-
301
- if single? && parents.length > 1
302
- raise Errors::SingularSideload.new(self, parents.length)
303
- end
304
-
305
- if self.class.scope_proc
306
- sideload_scope = fire_scope(parents)
307
- sideload_scope = Scope.new sideload_scope,
308
- resource,
309
- query,
310
- parent: graph_parent,
311
- sideload: self,
312
- sideload_parent_length: parents.length,
313
- default_paginate: false
314
- sideload_scope.resolve do |sideload_results|
315
- fire_assign(parents, sideload_results)
316
- end
313
+ if Graphiti.config.concurrency
314
+ future_resolve(parents, query, graph_parent).value!
317
315
  else
318
- load(parents, query, graph_parent)
316
+ sync_resolve(parents, query, graph_parent)
319
317
  end
320
318
  end
321
319
 
322
320
  def future_resolve(parents, query, graph_parent)
323
- if single? && parents.length > 1
324
- raise Errors::SingularSideload.new(self, parents.length)
325
- end
321
+ assert_singular!(parents)
326
322
 
327
323
  if self.class.scope_proc
328
- sideload_scope = fire_scope(parents)
329
- sideload_scope = Scope.new sideload_scope,
330
- resource,
331
- query,
332
- parent: graph_parent,
333
- sideload: self,
334
- sideload_parent_length: parents.length,
335
- default_paginate: false
336
- sideload_scope.future_resolve do |sideload_results|
324
+ build_sideload_scope(parents, query, graph_parent).future_resolve do |sideload_results|
337
325
  fire_assign(parents, sideload_results)
338
326
  end
339
327
  else
@@ -401,6 +389,37 @@ module Graphiti
401
389
 
402
390
  private
403
391
 
392
+ # Synchronous counterpart to #future_resolve, used when concurrency is off.
393
+ # Resolves inline via the synchronous Scope#resolve / #load paths (no
394
+ # promises). See Scope#sync_resolve_sideloads.
395
+ def sync_resolve(parents, query, graph_parent)
396
+ assert_singular!(parents)
397
+
398
+ if self.class.scope_proc
399
+ build_sideload_scope(parents, query, graph_parent).resolve do |sideload_results|
400
+ fire_assign(parents, sideload_results)
401
+ end
402
+ else
403
+ load(parents, query, graph_parent)
404
+ end
405
+ end
406
+
407
+ def assert_singular!(parents)
408
+ if single? && parents.length > 1
409
+ raise Errors::SingularSideload.new(self, parents.length)
410
+ end
411
+ end
412
+
413
+ def build_sideload_scope(parents, query, graph_parent)
414
+ Scope.new fire_scope(parents),
415
+ resource,
416
+ query,
417
+ parent: graph_parent,
418
+ sideload: self,
419
+ sideload_parent_length: parents.length,
420
+ default_paginate: false
421
+ end
422
+
404
423
  def future_load(parents, query, graph_parent)
405
424
  proxy = build_resource_proxy(parents, query, graph_parent)
406
425
  proxy.respond_to?(:future_resolve_data) ? proxy.future_resolve_data : Concurrent::Promises.fulfilled_future(proxy)
@@ -0,0 +1,73 @@
1
+ module Graphiti
2
+ module SpecHelpers
3
+ module Errors
4
+ class Base < StandardError; end
5
+
6
+ class LinksNotFound < Base
7
+ def initialize(name)
8
+ @name = name
9
+ end
10
+
11
+ def message
12
+ "Relationship with name '#{@name}' has no links!"
13
+ end
14
+ end
15
+
16
+ class NoAttribute < Base
17
+ def initialize(name)
18
+ @name = name
19
+ end
20
+
21
+ def message
22
+ "No attribute '#{@name}' in JSON response node!"
23
+ end
24
+ end
25
+
26
+ class SideloadNotFound < Base
27
+ def initialize(name)
28
+ @name = name
29
+ end
30
+
31
+ def message
32
+ "Relationship with name '#{@name}' not found!"
33
+ end
34
+ end
35
+
36
+ class NoResponse < Base
37
+ def message
38
+ "Cannot parse response - missing #response.body!"
39
+ end
40
+ end
41
+
42
+ class NoData < Base
43
+ def initialize(payload)
44
+ @payload = payload
45
+ end
46
+
47
+ def message
48
+ "Payload did not contain 'data'! Payload was:\n\n#{JSON.pretty_generate(@payload)}"
49
+ end
50
+ end
51
+
52
+ class NoSideloads < Base
53
+ def initialize(payload)
54
+ @payload = payload
55
+ end
56
+
57
+ def message
58
+ "Tried to find sideload, but the payload did not contain 'included'! Payload was:\n\n#{JSON.pretty_generate(@payload)}"
59
+ end
60
+ end
61
+
62
+ class NoMeta < Base
63
+ def initialize(payload)
64
+ @payload = payload
65
+ end
66
+
67
+ def message
68
+ "Tried to find metadata, but the payload did not contain 'meta'! Payload was:\n\n#{JSON.pretty_generate(@payload)}"
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,75 @@
1
+ module Graphiti
2
+ module SpecHelpers
3
+ class ErrorsProxy
4
+ class Error
5
+ attr_reader :json
6
+
7
+ def initialize(json)
8
+ @json = json
9
+ end
10
+
11
+ def attribute
12
+ @json[:meta][:attribute]
13
+ end
14
+
15
+ # TODO: move to top-level code in errorable
16
+ def code
17
+ @json[:meta][:code]
18
+ end
19
+
20
+ def message
21
+ @json[:meta][:message]
22
+ end
23
+
24
+ def title
25
+ @json[:title]
26
+ end
27
+
28
+ def detail
29
+ @json[:detail]
30
+ end
31
+
32
+ def status
33
+ @json[:status]
34
+ end
35
+ end
36
+
37
+ include Enumerable
38
+
39
+ def initialize(array)
40
+ @errors = array.map { |e| Error.new(e) }
41
+ end
42
+
43
+ def [](key)
44
+ @errors[key]
45
+ end
46
+
47
+ def each(&blk)
48
+ @errors.each(&blk)
49
+ end
50
+
51
+ def length
52
+ count
53
+ end
54
+
55
+ def to_h
56
+ {}.tap do |hash|
57
+ @errors.each do |e|
58
+ hash[e.attribute] = e.message
59
+ end
60
+ end
61
+ end
62
+
63
+ def method_missing(id, *args, &blk)
64
+ matches = @errors.select { |e| e.attribute.to_sym == id }
65
+ matches.length == 1 ? matches[0] : matches
66
+ end
67
+
68
+ # Any name is answered: an attribute with no matching error yields an empty
69
+ # array rather than raising NoMethodError.
70
+ def respond_to_missing?(id, include_private = false)
71
+ true
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,107 @@
1
+ module Graphiti
2
+ module SpecHelpers
3
+ module Helpers
4
+ extend ActiveSupport::Concern
5
+
6
+ def json
7
+ if response&.body
8
+ JSON.parse(response.body).with_indifferent_access
9
+ else
10
+ raise Errors::NoResponse.new
11
+ end
12
+ end
13
+
14
+ def jsonapi_data
15
+ @jsonapi_data ||= if _jsonapi_data.is_a?(Hash)
16
+ node(from: _jsonapi_data)
17
+ else
18
+ _jsonapi_data.map { |datum| node(from: datum) }
19
+ end
20
+ end
21
+
22
+ def jsonapi_included(type = nil)
23
+ variable = :"@jsonapi_included#{type}"
24
+ memo = instance_variable_get(variable)
25
+ return memo if memo
26
+
27
+ nodes = _jsonapi_included.map { |i| node(from: i) }
28
+ if type
29
+ nodes.select! { |n| n.jsonapi_type == type.to_s }
30
+ end
31
+ instance_variable_set(variable, nodes)
32
+ nodes
33
+ end
34
+
35
+ def jsonapi_errors
36
+ @jsonapi_errors ||= ErrorsProxy.new(json["errors"] || [])
37
+ end
38
+
39
+ def jsonapi_meta
40
+ @jsonapi_errors = json["meta"] || raise(Errors::NoMeta.new(json))
41
+ end
42
+
43
+ def jsonapi_headers
44
+ media_type = "application/vnd.api+json"
45
+ {
46
+ "CONTENT_TYPE" => media_type,
47
+ "HTTP_ACCEPT" => media_type
48
+ }
49
+ end
50
+
51
+ def jsonapi_get(url, params: {}, headers: {})
52
+ get url_for(url), params: params, headers: jsonapi_headers.merge(headers)
53
+ end
54
+
55
+ def jsonapi_post(url, payload, headers: {})
56
+ post url_for(url), params: payload.to_json, headers: jsonapi_headers.merge(headers)
57
+ end
58
+
59
+ def jsonapi_put(url, payload, headers: {})
60
+ put url_for(url), params: payload.to_json, headers: jsonapi_headers.merge(headers)
61
+ end
62
+
63
+ def jsonapi_patch(url, payload, headers: {})
64
+ patch url_for(url), params: payload.to_json, headers: jsonapi_headers.merge(headers)
65
+ end
66
+
67
+ def jsonapi_delete(url, headers: {})
68
+ delete url_for(url), headers: jsonapi_headers.merge(headers)
69
+ end
70
+
71
+ def json_datetime(value)
72
+ Graphiti::Types[:datetime][:read][value].as_json
73
+ end
74
+
75
+ def json_date(value)
76
+ Graphiti::Types[:date][:read][value].as_json
77
+ end
78
+
79
+ # @api private
80
+ def node(from: nil)
81
+ from = json if from.nil?
82
+ data = from.has_key?("data") ? from["data"] : from
83
+ hash = {}
84
+ hash["id"] = data["id"]
85
+ hash["jsonapi_type"] = data["type"]
86
+ hash.merge!(data["attributes"]) if data.has_key?("attributes")
87
+ Node.new(hash, data["relationships"], self)
88
+ end
89
+
90
+ private
91
+
92
+ # @api private
93
+ def _jsonapi_data
94
+ json["data"] || raise(Errors::NoData.new(json))
95
+ end
96
+
97
+ # @api private
98
+ def _jsonapi_included
99
+ if json.has_key?("included")
100
+ json["included"]
101
+ else
102
+ raise Errors::NoSideloads.new(json)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,196 @@
1
+ module Graphiti
2
+ module SpecHelpers
3
+ # Assertions against a resource's DSL. The schema specs cannot cover
4
+ # adapter-dependent options (primary key, foreign key, and so on) because
5
+ # the schema does not carry them; these read the resource config directly.
6
+ module Matchers
7
+ class BaseMatcher
8
+ GRAPHITI_OPTS = [].freeze
9
+ GRAPHITI_CONFIG_KEY = ""
10
+ EXPECTED_ACTION = ""
11
+
12
+ def description
13
+ "#{self.class::EXPECTED_ACTION} #{target}"
14
+ end
15
+
16
+ def failure_message
17
+ "expected that #{resource.class} would #{self.class::EXPECTED_ACTION} #{target}\n#{@opt_failures.join("\n")}"
18
+ end
19
+
20
+ def failure_message_when_negated
21
+ "expected that #{resource.class} would not #{self.class::EXPECTED_ACTION} #{target}"
22
+ end
23
+
24
+ def opt_failure_message(opt, expected, actual)
25
+ "expected that #{opt} would be #{expected}, was #{actual}"
26
+ end
27
+
28
+ def does_not_match?(resource)
29
+ !matches?(resource)
30
+ end
31
+
32
+ def matches?(resource)
33
+ @resource = resource
34
+
35
+ expected? && expected_opts?
36
+ end
37
+
38
+ def with_options(opts)
39
+ @opts = opts
40
+ self
41
+ end
42
+
43
+ private
44
+
45
+ def actual_opts
46
+ self.class::GRAPHITI_OPTS & opts.keys
47
+ end
48
+
49
+ def config
50
+ @config ||= resource.class.config[self.class::GRAPHITI_CONFIG_KEY][target]
51
+ end
52
+
53
+ def expected_opts?
54
+ return false unless config
55
+
56
+ actual_opts.map { |opt| assert_opt(opt) }.all?(true)
57
+ end
58
+ end
59
+
60
+ class RelationMatcher < BaseMatcher
61
+ GRAPHITI_OPTS = %i[primary_key foreign_key resource readable writable link single].freeze
62
+ GRAPHITI_CONFIG_KEY = :sideloads
63
+ EXPECTED_ACTION = ""
64
+
65
+ def initialize(target)
66
+ @target = target
67
+ @opts = {}
68
+ @opt_failures = []
69
+ end
70
+
71
+ private
72
+
73
+ attr_reader :target, :opts, :resource
74
+
75
+ def assert_opt(opt)
76
+ asserted_opt = opt == :resource ? :resource_class : opt
77
+ return true if config.send(asserted_opt) == opts[opt]
78
+
79
+ @opt_failures << opt_failure_message(opt, opts[opt], config.send(asserted_opt))
80
+ false
81
+ end
82
+
83
+ def expected?
84
+ config && config.type == relation_name
85
+ end
86
+
87
+ # BelongsToMatcher -> :belongs_to, matching Sideload#type.
88
+ def relation_name
89
+ self.class.name.demodulize.gsub("Matcher", "").underscore.to_sym
90
+ end
91
+ end
92
+
93
+ class BelongsToMatcher < RelationMatcher
94
+ EXPECTED_ACTION = "belong to"
95
+ end
96
+
97
+ class HasManyMatcher < RelationMatcher
98
+ EXPECTED_ACTION = "have many"
99
+ end
100
+
101
+ class HasOneMatcher < RelationMatcher
102
+ EXPECTED_ACTION = "have one"
103
+ end
104
+
105
+ class ResourceDSLMatcher < BaseMatcher
106
+ def initialize(target, type)
107
+ @target = target
108
+ @type = type
109
+ @opts = {}
110
+ @opt_failures = []
111
+ end
112
+
113
+ private
114
+
115
+ attr_reader :target, :type, :opts, :resource
116
+
117
+ def expected?
118
+ config && assert_type
119
+ end
120
+
121
+ def assert_type
122
+ return true if config[:type] == type
123
+
124
+ @opt_failures << opt_failure_message("type", type, config[:type])
125
+ false
126
+ end
127
+
128
+ def assert_opt(opt)
129
+ return true if config[opt] == opts[opt]
130
+
131
+ @opt_failures << opt_failure_message(opt, opts[opt], config[opt])
132
+ false
133
+ end
134
+ end
135
+
136
+ class ExposeAttributeMatcher < ResourceDSLMatcher
137
+ GRAPHITI_OPTS = %i[writable readable sortable filterable].freeze
138
+ GRAPHITI_CONFIG_KEY = :attributes
139
+ EXPECTED_ACTION = "expose"
140
+ end
141
+
142
+ class FilterAttributeMatcher < ResourceDSLMatcher
143
+ GRAPHITI_OPTS = %i[allow deny single required allow_nil deny_empty].freeze
144
+ GRAPHITI_CONFIG_KEY = :filters
145
+ EXPECTED_ACTION = "filter"
146
+ end
147
+
148
+ # @param [Symbol] relation
149
+ #
150
+ # @example expect(subject).to belong_to_resource(:user)
151
+ # @example expect(subject).to belong_to_resource(:user).with_options(foreign_key: :user_id, resource: UserResource)
152
+ # @example expect(subject).not_to belong_to_resource(:user)
153
+ def belong_to_resource(relation)
154
+ BelongsToMatcher.new(relation)
155
+ end
156
+
157
+ # @param [Symbol] relation
158
+ #
159
+ # @example expect(subject).to have_many_resources(:posts)
160
+ # @example expect(subject).to have_many_resources(:posts).with_options(foreign_key: :post_id, resource: PostResource)
161
+ # @example expect(subject).not_to have_many_resources(:posts)
162
+ def have_many_resources(relation)
163
+ HasManyMatcher.new(relation)
164
+ end
165
+
166
+ # @param [Symbol] relation
167
+ #
168
+ # @example expect(subject).to have_one_resource(:post)
169
+ # @example expect(subject).to have_one_resource(:post).with_options(foreign_key: :post_id, resource: PostResource)
170
+ # @example expect(subject).not_to have_one_resource(:post)
171
+ def have_one_resource(relation)
172
+ HasOneMatcher.new(relation)
173
+ end
174
+
175
+ # @param [Symbol] attribute
176
+ # @param [Symbol] type
177
+ #
178
+ # @example expect(subject).to expose_attribute(:name, :string)
179
+ # @example expect(subject).to expose_attribute(:name, :string).with_options(writable: false)
180
+ # @example expect(subject).not_to expose_attribute(:name, :string)
181
+ def expose_attribute(attribute, type)
182
+ ExposeAttributeMatcher.new(attribute, type)
183
+ end
184
+
185
+ # @param [Symbol] attribute
186
+ # @param [Symbol] type
187
+ #
188
+ # @example expect(subject).to filter_attribute(:name, :string)
189
+ # @example expect(subject).to filter_attribute(:name, :string).with_options(allow_nil: false)
190
+ # @example expect(subject).not_to filter_attribute(:name, :string)
191
+ def filter_attribute(attribute, type)
192
+ FilterAttributeMatcher.new(attribute, type)
193
+ end
194
+ end
195
+ end
196
+ end