helpers.ex (11782B)
1 defmodule Absinthe.Resolution.Helpers do 2 @moduledoc """ 3 Handy functions for returning async or batched resolution functions 4 5 Using `Absinthe.Schema.Notation` or (by extension) `Absinthe.Schema` will 6 automatically import the `batch` and `async` helpers. Dataloader helpers 7 require an explicit `import Absinthe.Resolution.Helpers` invocation, since 8 dataloader is an optional dependency. 9 """ 10 11 alias Absinthe.Middleware 12 13 @doc """ 14 Execute resolution field asynchronously. 15 16 This is a helper function for using the `Absinthe.Middleware.Async`. 17 18 Forbidden in mutation fields. (TODO: actually enforce this) 19 20 ## Options 21 - `:timeout` default: `30_000`. The maximum timeout to wait for running 22 the task. 23 24 ## Example 25 26 Using the `Absinthe.Resolution.Helpers.async/1` helper function: 27 ```elixir 28 field :time_consuming, :thing do 29 resolve fn _, _, _ -> 30 async(fn -> 31 {:ok, long_time_consuming_function()} 32 end) 33 end 34 end 35 ``` 36 """ 37 @spec async((() -> term)) :: {:middleware, Middleware.Async, term} 38 @spec async((() -> term), opts :: [{:timeout, pos_integer}]) :: 39 {:middleware, Middleware.Async, term} 40 def async(fun, opts \\ []) do 41 {:middleware, Middleware.Async, {fun, opts}} 42 end 43 44 @doc """ 45 Batch the resolution of several functions together. 46 47 Helper function for creating `Absinthe.Middleware.Batch` 48 49 ## Options 50 - `:timeout` default: `5_000`. The maximum timeout to wait for running 51 a batch. 52 53 ## Example 54 55 Raw usage: 56 ```elixir 57 object :post do 58 field :name, :string 59 field :author, :user do 60 resolve fn post, _, _ -> 61 batch({__MODULE__, :users_by_id}, post.author_id, fn batch_results -> 62 {:ok, Map.get(batch_results, post.author_id)} 63 end) 64 end 65 end 66 end 67 68 def users_by_id(_, user_ids) do 69 users = Repo.all from u in User, where: u.id in ^user_ids 70 Map.new(users, fn user -> {user.id, user} end) 71 end 72 ``` 73 """ 74 @spec batch(Middleware.Batch.batch_fun(), term, Middleware.Batch.post_batch_fun()) :: 75 {:middleware, Middleware.Batch, term} 76 @spec batch( 77 Middleware.Batch.batch_fun(), 78 term, 79 Middleware.Batch.post_batch_fun(), 80 opts :: [{:timeout, pos_integer}] 81 ) :: {:middleware, Middleware.Batch, term} 82 def batch(batch_fun, batch_data, post_batch_fun, opts \\ []) do 83 batch_config = {batch_fun, batch_data, post_batch_fun, opts} 84 {:middleware, Middleware.Batch, batch_config} 85 end 86 87 if Code.ensure_loaded?(Dataloader) do 88 @doc """ 89 Dataloader helper function 90 91 This function is not imported by default. To make it available in your module do 92 93 ``` 94 import Absinthe.Resolution.Helpers 95 ``` 96 97 This function helps you use data loader in a direct way within your schema. 98 While normally the `dataloader/1,2,3` helpers are enough, `on_load/2` is useful 99 when you want to load multiple things in a single resolver, or when you need 100 fine grained control over the dataloader cache. 101 102 ## Examples 103 104 ```elixir 105 field :reports, list_of(:report) do 106 resolve fn shipment, _, %{context: %{loader: loader}} -> 107 loader 108 |> Dataloader.load(SourceName, :automatic_reports, shipment) 109 |> Dataloader.load(SourceName, :manual_reports, shipment) 110 |> on_load(fn loader -> 111 reports = 112 loader 113 |> Dataloader.get(SourceName, :automatic_reports, shipment) 114 |> Enum.concat(Dataloader.get(loader, SourceName, :manual_reports, shipment)) 115 |> Enum.sort_by(&reported_at/1) 116 {:ok, reports} 117 end) 118 end 119 end 120 ``` 121 """ 122 def on_load(loader, fun) do 123 {:middleware, Absinthe.Middleware.Dataloader, {loader, fun}} 124 end 125 126 @type dataloader_tuple :: {:middleware, Absinthe.Middleware.Dataloader, term} 127 @type dataloader_key_fun :: 128 (Absinthe.Resolution.source(), 129 Absinthe.Resolution.arguments(), 130 Absinthe.Resolution.t() -> 131 {any, map}) 132 @type dataloader_opt :: 133 {:args, map} 134 | {:use_parent, true | false} 135 | {:callback, (map(), map(), map() -> any())} 136 137 @doc """ 138 Resolve a field with a dataloader source. 139 140 This function is not imported by default. To make it available in your module do 141 142 ``` 143 import Absinthe.Resolution.Helpers 144 ``` 145 146 Same as `dataloader/3`, but it infers the resource name from the field name. 147 148 ## Examples 149 150 ``` 151 field :author, :user, resolve: dataloader(Blog) 152 ``` 153 154 This is identical to doing the following. 155 156 ``` 157 field :author, :user, resolve: dataloader(Blog, :author, []) 158 ``` 159 """ 160 @spec dataloader(Dataloader.source_name()) :: dataloader_key_fun() 161 def dataloader(source) do 162 dataloader(source, []) 163 end 164 165 @doc """ 166 Resolve a field with a dataloader source. 167 168 This function is not imported by default. To make it available in your module do 169 170 ``` 171 import Absinthe.Resolution.Helpers 172 ``` 173 174 Same as `dataloader/3`, but it infers the resource name from the field name. For `opts` see 175 `dataloader/3` on what options can be passed in. 176 177 ## Examples 178 179 ``` 180 object :user do 181 field :posts, list_of(:post), 182 resolve: dataloader(Blog, args: %{deleted: false}) 183 184 field :organization, :organization do 185 resolve dataloader(Accounts, use_parent: false) 186 end 187 188 field(:account_active, non_null(:boolean), resolve: dataloader( 189 Accounts, callback: fn account, _parent, _args -> 190 {:ok, account.active} 191 end 192 ) 193 ) 194 end 195 ``` 196 197 """ 198 @dialyzer {:no_contracts, dataloader: 2} 199 @spec dataloader(Dataloader.source_name(), [dataloader_opt]) :: dataloader_key_fun() 200 def dataloader(source, opts) when is_list(opts) do 201 fn parent, args, %{context: %{loader: loader}} = res -> 202 resource = res.definition.schema_node.identifier 203 do_dataloader(loader, source, {resource, args}, parent, opts) 204 end 205 end 206 207 @doc """ 208 Resolve a field with Dataloader 209 210 This function is not imported by default. To make it available in your module do 211 212 ``` 213 import Absinthe.Resolution.Helpers 214 ``` 215 216 While `on_load/2` makes using dataloader directly easy within a resolver function, 217 it is often unnecessary to need this level of direct control. 218 219 The `dataloader/3` function exists to provide a simple API for using dataloader. 220 It takes the name of a data source, the name of the resource you want to load, 221 and then a variety of options. 222 223 ## Basic Usage 224 225 ``` 226 object :user do 227 field :posts, list_of(:post), 228 resolve: dataloader(Blog, :posts, args: %{deleted: false}) 229 230 field :organization, :organization do 231 resolve dataloader(Accounts, :organization, use_parent: false) 232 end 233 234 field(:account_active, non_null(:boolean), resolve: dataloader( 235 Accounts, :account, callback: fn account, _parent, _args -> 236 {:ok, account.active} 237 end 238 ) 239 ) 240 end 241 ``` 242 243 ## Key Functions 244 245 Instead of passing in a literal like `:posts` or `:organization` in as the resource, 246 it is also possible pass in a function: 247 248 ``` 249 object :user do 250 field :posts, list_of(:post) do 251 arg :limit, non_null(:integer) 252 resolve dataloader(Blog, fn user, args, info -> 253 args = Map.update!(args, :limit, fn val -> 254 max(min(val, 20), 0) 255 end) 256 {:posts, args} 257 end) 258 end 259 end 260 ``` 261 262 In this case we want to make sure that the limit value cannot be larger than 263 `20`. By passing a callback function to `dataloader/2` we can ensure that 264 the value will fall nicely between 0 and 20. 265 266 ## Options 267 268 - `:args` default: `%{}`. Any arguments you want to always pass into the 269 `Dataloader.load/4` call. Resolver arguments are merged into this value and, 270 in the event of a conflict, the resolver arguments win. 271 - `:callback` default: return result wrapped in ok or error tuple. 272 Callback that is run with result of dataloader. It receives the result as 273 the first argument, and the parent and args as second and third. Can be used 274 to e.g. compute fields on the return value of the loader. Should return an 275 ok or error tuple. 276 - `:use_parent` default: `false`. This option affects whether or not the `dataloader/2` 277 helper will use any pre-existing value on the parent. IE if you return 278 `%{author: %User{...}}` from a blog post the helper will by default simply use 279 the pre-existing author. Set it to true if you want to opt into using the 280 pre-existing value instead of loading it fresh. 281 282 Ultimately, this helper calls `Dataloader.load/4` 283 using the loader in your context, the source you provide, the tuple `{resource, args}` 284 as the batch key, and then the parent value of the field 285 286 ``` 287 def dataloader(source_name, resource) do 288 fn parent, args, %{context: %{loader: loader}} -> 289 args = Map.merge(opts[:args] || %{}, args) 290 loader 291 |> Dataloader.load(source_name, {resource, args}, parent) 292 |> on_load(fn loader -> 293 {:ok, Dataloader.get(loader, source_name, {resource, args}, parent)} 294 end) 295 end 296 end 297 ``` 298 299 """ 300 def dataloader(source, fun, opts \\ []) 301 302 @spec dataloader(Dataloader.source_name(), any) :: dataloader_key_fun 303 @spec dataloader(Dataloader.source_name(), dataloader_key_fun | any, [dataloader_opt]) :: 304 dataloader_key_fun 305 def dataloader(source, fun, opts) when is_function(fun, 3) do 306 fn parent, args, %{context: %{loader: loader}} = res -> 307 {batch_key, parent} = 308 case fun.(parent, args, res) do 309 {resource, args} -> {{resource, args}, parent} 310 %{batch: batch, item: item} -> {batch, item} 311 end 312 313 do_dataloader(loader, source, batch_key, parent, opts) 314 end 315 end 316 317 def dataloader(source, resource, opts) do 318 fn parent, args, %{context: %{loader: loader}} -> 319 do_dataloader(loader, source, {resource, args}, parent, opts) 320 end 321 end 322 323 defp use_parent(loader, source, batch_key, parent, opts) when is_map(parent) do 324 resource = 325 case batch_key do 326 {_cardinality, resource, _args} -> resource 327 {resource, _args} -> resource 328 end 329 330 with true <- Keyword.get(opts, :use_parent, false), 331 {:ok, val} <- Map.fetch(parent, resource) do 332 Dataloader.put(loader, source, batch_key, parent, val) 333 else 334 _ -> loader 335 end 336 end 337 338 defp use_parent(loader, _source, _batch_key, _parent, _opts), do: loader 339 340 defp do_dataloader(loader, source, batch_key, parent, opts) do 341 args_from_opts = Keyword.get(opts, :args, %{}) 342 343 {batch_key, args} = 344 case batch_key do 345 {cardinality, resource, args} -> 346 args = Map.merge(args_from_opts, args) 347 {{cardinality, resource, args}, args} 348 349 {resource, args} -> 350 args = Map.merge(args_from_opts, args) 351 {{resource, args}, args} 352 end 353 354 loader 355 |> use_parent(source, batch_key, parent, opts) 356 |> Dataloader.load(source, batch_key, parent) 357 |> on_load(fn loader -> 358 callback = Keyword.get(opts, :callback, default_callback(loader)) 359 360 loader 361 |> Dataloader.get(source, batch_key, parent) 362 |> callback.(parent, args) 363 end) 364 end 365 366 defp default_callback(%{options: loader_options}) do 367 if loader_options[:get_policy] == :tuples do 368 fn result, _parent, _args -> result end 369 else 370 fn result, _parent, _args -> {:ok, result} end 371 end 372 end 373 end 374 end