├── test ├── test_helper.exs ├── async_with │ ├── clauses_test.exs │ └── macro_test.exs └── async_with_test.exs ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .formatter.exs ├── lib ├── async_with │ ├── clause_error.ex │ ├── application.ex │ ├── runner.ex │ ├── macro.ex │ └── clauses.ex └── async_with.ex ├── .gitignore ├── LICENSE ├── CHANGELOG.md ├── mix.exs ├── mix.lock ├── .credo.exs └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: mix 5 | directory: "/" 6 | schedule: 7 | interval: daily 8 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["{mix,.formatter,.credo}.exs", "{config,lib,test}/**/*.{ex,exs}"], 3 | locals_without_parens: [async: 1, async: 2], 4 | export: [locals_without_parens: [async: 1, async: 2]] 5 | ] 6 | -------------------------------------------------------------------------------- /lib/async_with/clause_error.ex: -------------------------------------------------------------------------------- 1 | defmodule AsyncWith.ClauseError do 2 | defexception [:term] 3 | 4 | @impl true 5 | def message(exception) do 6 | "no async with clause matching: #{inspect(exception.term)}" 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/async_with/clauses_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AsyncWith.ClausesTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias AsyncWith.Clauses 5 | alias AsyncWith.Clauses.Clause 6 | 7 | doctest Clauses 8 | doctest Clause 9 | end 10 | -------------------------------------------------------------------------------- /lib/async_with/application.ex: -------------------------------------------------------------------------------- 1 | defmodule AsyncWith.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | @impl true 7 | def start(_type, _args) do 8 | children = [ 9 | {Task.Supervisor, name: AsyncWith.TaskSupervisor} 10 | ] 11 | 12 | Supervisor.start_link(children, strategy: :one_for_one, name: AsyncWith.Supervisor) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | /docs/ 13 | 14 | # Ignore .fetch files in case you like to edit your project deps locally. 15 | /.fetch 16 | 17 | # If the VM crashes, it generates a dump, let's ignore it too. 18 | erl_crash.dump 19 | 20 | # Also ignore archive artifacts (built via "mix archive.build"). 21 | *.ez 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2017-2018 Fernando Tapia Rico 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /test/async_with/macro_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AsyncWith.MacroTest do 2 | use ExUnit.Case, async: true 3 | 4 | doctest AsyncWith.Macro 5 | 6 | test "get_vars/1 ignores the AST for string interpolations" do 7 | ast = quote(do: {^ok, {^ok, a}, ^b} <- echo("#{c} #{d}")) 8 | 9 | assert AsyncWith.Macro.get_vars(ast) == [:ok, :a, :b, :c, :d] 10 | end 11 | 12 | test "get_pinned_vars/1 returns a list of variables without duplicates" do 13 | ast = quote(do: {^ok, {^ok, a}, ^b} <- echo(c, d)) 14 | 15 | assert AsyncWith.Macro.get_pinned_vars(ast) == [:ok, :b] 16 | end 17 | 18 | test "get_guard_vars/1 returns a list of variables without duplicates" do 19 | ast = quote(do: {:ok, a} when not is_atom(a) and not is_list(a) <- echo(b)) 20 | 21 | assert AsyncWith.Macro.get_guard_vars(ast) == [:a] 22 | end 23 | 24 | test "var?/1 returns false with the special _ variable" do 25 | refute AsyncWith.Macro.var?({:_, [], nil}) 26 | end 27 | 28 | test "var?/1 returns false with the AST for string interpolations" do 29 | refute AsyncWith.Macro.var?({:binary, [], nil}) 30 | end 31 | 32 | test "map_vars/2 ignores the AST for string interpolations" do 33 | ast = quote(do: [^a, {1, %{b: "#{c} #{d}"}, [e: ^f]}, _]) 34 | fun = fn {var, meta, context} -> {:"var_#{var}", meta, context} end 35 | 36 | string = ast |> AsyncWith.Macro.map_vars(fun) |> Macro.to_string() 37 | 38 | assert string == ~S([^var_a, {1, %{b: "#{var_c} #{var_d}"}, [e: ^var_f]}, _]) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.3.0 4 | 5 | ### Enhancements 6 | 7 | * Support `use AsyncWith` outside of a module. This allows interactive IEx sessions. 8 | * Raise `CompilerError` instead of `ArgumentError` when the `async` macro is not used with `with`. 9 | * Raise `CompilerError` errors when no clauses nor blocks are provided. 10 | * Export formatter configuration via `.formatter.exs`. 11 | * Support single line usage (i.e. `async with a <- 1, do: a`). 12 | * Re-throw uncaught values (i.e. `async with _ <- throw(:foo), do: :ok`). 13 | * Re-raise unrescued errors (i.e. `async with _ <- raise("ops"), do: :ok`). 14 | 15 | ## v0.2.2 16 | 17 | ### Enhancements 18 | 19 | * Print a warning message when using `else` clauses that will never match because all patterns in `async with` will always match. 20 | 21 | ### Bug fixes 22 | 23 | * Fix compiler warnings produced when one of the `async with` clauses followed an always match pattern (i.e. `a <- 1`). 24 | 25 | ## v0.2.1 26 | 27 | ### Enhancements 28 | 29 | * Correct documentation regarding `@async_with_timeout` attribute. 30 | 31 | ## v0.2.0 32 | 33 | ### Enhancements 34 | 35 | * Optimize implementation. 36 | * Use same timeout exit format as `Task`. 37 | 38 | ### Bug fixes 39 | 40 | * Ensure asynchronous execution of all clauses as soon as their dependencies are fulfilled. 41 | 42 | ### Deprecations 43 | 44 | * `DOT` is removed. 45 | * `DependencyGraph` is removed. 46 | * `DependencyGraph.Vertex` is removed. 47 | * `Macro.DependencyGraph` is removed. 48 | * `Macro.OutNeighbours` is removed. 49 | * `Macro.Vertex` is removed. 50 | * `Clause` is now private. 51 | * `Macro` is now private. 52 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule AsyncWith.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.3.0" 5 | 6 | def project do 7 | [ 8 | app: :async_with, 9 | version: @version, 10 | elixir: "~> 1.7", 11 | deps: deps(), 12 | package: package(), 13 | preferred_cli_env: [docs: :docs, "hex.publish": :docs], 14 | description: description(), 15 | docs: docs(), 16 | dialyzer: dialyzer() 17 | ] 18 | end 19 | 20 | def application do 21 | [ 22 | extra_applications: [:logger], 23 | mod: {AsyncWith.Application, []} 24 | ] 25 | end 26 | 27 | defp deps do 28 | [ 29 | {:dialyxir, "~> 1.1", only: [:dev], runtime: false}, 30 | {:ex_doc, "~> 0.24.2", only: :docs} 31 | ] 32 | end 33 | 34 | def description do 35 | """ 36 | The asynchronous version of Elixir's "with", resolving the dependency graph and executing 37 | the clauses in the most performant way possible! 38 | """ 39 | end 40 | 41 | defp package do 42 | [ 43 | maintainers: ["Fernando Tapia Rico"], 44 | licenses: ["MIT"], 45 | links: %{"GitHub" => "https://github.com/fertapric/async_with"} 46 | ] 47 | end 48 | 49 | defp docs do 50 | [ 51 | source_ref: "v#{@version}", 52 | main: "AsyncWith", 53 | canonical: "http://hexdocs.pm/async_with", 54 | source_url: "https://github.com/fertapric/async_with" 55 | ] 56 | end 57 | 58 | defp dialyzer do 59 | plt_core_path = System.get_env("DIALYZER_PLT_CORE_PATH") || Mix.Utils.mix_home() 60 | plt_local_path = System.get_env("DIALYZER_PLT_LOCAL_PATH") || Mix.Project.build_path() 61 | 62 | [ 63 | plt_core_path: plt_core_path, 64 | plt_file: {:no_warn, Path.join(plt_local_path, "async_with.plt")}, 65 | plt_add_deps: :transitive, 66 | flags: [:unmatched_returns, :error_handling, :race_conditions, :underspecs] 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, 4 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 5 | "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, 6 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 8 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 9 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | test: 11 | name: Test (Elixir ${{ matrix.elixir }} | Erlang/OTP ${{ matrix.otp }}) 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | include: 17 | - elixir: 1.7.4 18 | otp: 20.3.8.26 19 | - elixir: 1.8.2 20 | otp: 20.3.8.26 21 | - elixir: 1.9.4 22 | otp: 20.3.8.26 23 | - elixir: 1.10.4 24 | otp: 21.3.8.17 25 | - elixir: 1.11.4 26 | otp: 23.2.7 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v2 30 | - name: Install Elixir 31 | uses: erlef/setup-beam@v1 32 | with: 33 | otp-version: ${{ matrix.otp }} 34 | elixir-version: ${{ matrix.elixir }} 35 | - name: Install dependencies 36 | run: | 37 | mix local.hex --force 38 | mix deps.get --only test 39 | - name: Run tests 40 | run: mix test --cover 41 | 42 | check: 43 | name: Check (Elixir ${{ matrix.elixir }} | Erlang/OTP ${{ matrix.otp }}) 44 | runs-on: ubuntu-latest 45 | env: 46 | DIALYZER_PLT_CORE_PATH: priv/plts 47 | DIALYZER_PLT_LOCAL_PATH: priv/plts 48 | strategy: 49 | matrix: 50 | include: 51 | - elixir: 1.11.4 52 | otp: 23.2.7 53 | steps: 54 | - name: Checkout 55 | uses: actions/checkout@v2 56 | - name: Install Elixir 57 | uses: erlef/setup-beam@v1 58 | with: 59 | otp-version: ${{ matrix.otp }} 60 | elixir-version: ${{ matrix.elixir }} 61 | - name: Install dependencies 62 | run: | 63 | mix local.hex --force 64 | mix deps.get 65 | - name: Restore Dialyzer cache 66 | uses: actions/cache@v1 67 | id: dialyzer-cache 68 | with: 69 | path: priv/plts 70 | key: ${{ runner.os }}-erlang-${{ matrix.otp }}-elixir-${{ matrix.elixir }}-plts-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 71 | restore-keys: ${{ runner.os }}-erlang-${{ matrix.otp }}-elixir-${{ matrix.elixir }}-plts- 72 | - name: Check unused dependencies 73 | run: mix deps.unlock --check-unused 74 | - name: Check compilation warnings 75 | run: mix compile --warnings-as-errors 76 | - name: Check formatted 77 | run: mix format --check-formatted 78 | - name: Run Dialyzer 79 | run: | 80 | mkdir -p priv/plts 81 | mix dialyzer 82 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | files: %{ 6 | included: ["lib/", "config/", "test/"], 7 | excluded: ["/_build", "/deps"] 8 | }, 9 | requires: [], 10 | strict: true, 11 | color: true, 12 | checks: [ 13 | {Credo.Check.Consistency.ExceptionNames}, 14 | {Credo.Check.Consistency.LineEndings}, 15 | {Credo.Check.Consistency.MultiAliasImportRequireUse}, 16 | {Credo.Check.Consistency.ParameterPatternMatching}, 17 | {Credo.Check.Consistency.SpaceAroundOperators}, 18 | {Credo.Check.Consistency.SpaceInParentheses}, 19 | {Credo.Check.Consistency.TabsOrSpaces}, 20 | {Credo.Check.Design.AliasUsage}, 21 | {Credo.Check.Design.DuplicatedCode}, 22 | {Credo.Check.Design.TagTODO, exit_status: 2}, 23 | {Credo.Check.Design.TagFIXME}, 24 | {Credo.Check.Readability.FunctionNames}, 25 | {Credo.Check.Readability.LargeNumbers}, 26 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 100}, 27 | {Credo.Check.Readability.ModuleAttributeNames}, 28 | {Credo.Check.Readability.ModuleDoc}, 29 | {Credo.Check.Readability.ModuleNames}, 30 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, 31 | {Credo.Check.Readability.ParenthesesInCondition}, 32 | {Credo.Check.Readability.PredicateFunctionNames}, 33 | {Credo.Check.Readability.PreferImplicitTry}, 34 | {Credo.Check.Readability.RedundantBlankLines}, 35 | {Credo.Check.Readability.StringSigils}, 36 | {Credo.Check.Readability.TrailingBlankLine}, 37 | {Credo.Check.Readability.TrailingWhiteSpace}, 38 | {Credo.Check.Readability.VariableNames}, 39 | {Credo.Check.Readability.Semicolons}, 40 | {Credo.Check.Readability.SpaceAfterCommas}, 41 | {Credo.Check.Refactor.ABCSize, max_size: 40}, 42 | {Credo.Check.Refactor.AppendSingleItem}, 43 | {Credo.Check.Refactor.DoubleBooleanNegation}, 44 | {Credo.Check.Refactor.CondStatements}, 45 | {Credo.Check.Refactor.CyclomaticComplexity}, 46 | {Credo.Check.Refactor.FunctionArity}, 47 | {Credo.Check.Refactor.LongQuoteBlocks}, 48 | {Credo.Check.Refactor.MatchInCondition}, 49 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 50 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 51 | {Credo.Check.Refactor.Nesting}, 52 | {Credo.Check.Refactor.PipeChainStart}, 53 | {Credo.Check.Refactor.UnlessWithElse}, 54 | {Credo.Check.Refactor.VariableRebinding}, 55 | {Credo.Check.Warning.BoolOperationOnSameValues}, 56 | {Credo.Check.Warning.IExPry}, 57 | {Credo.Check.Warning.IoInspect}, 58 | {Credo.Check.Warning.LazyLogging}, 59 | {Credo.Check.Warning.MapGetUnsafePass}, 60 | {Credo.Check.Warning.OperationOnSameValues}, 61 | {Credo.Check.Warning.OperationWithConstantResult}, 62 | {Credo.Check.Warning.UnusedEnumOperation}, 63 | {Credo.Check.Warning.UnusedFileOperation}, 64 | {Credo.Check.Warning.UnusedKeywordOperation}, 65 | {Credo.Check.Warning.UnusedListOperation}, 66 | {Credo.Check.Warning.UnusedPathOperation}, 67 | {Credo.Check.Warning.UnusedRegexOperation}, 68 | {Credo.Check.Warning.UnusedStringOperation}, 69 | {Credo.Check.Warning.UnusedTupleOperation}, 70 | {Credo.Check.Warning.RaiseInsideRescue} 71 | ] 72 | } 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AsyncWith 2 | 3 | [![Build Status](https://github.com/fertapric/async_with/workflows/CI/badge.svg)](https://github.com/fertapric/async_with/actions?query=workflow%3ACI) 4 | 5 | The asynchronous version of Elixir's `with`, resolving the dependency graph and executing the clauses in the most performant way possible! 6 | 7 | ## Installation 8 | 9 | Add `async_with` to your project's dependencies in `mix.exs`: 10 | 11 | ```elixir 12 | def deps do 13 | [{:async_with, "~> 0.3"}] 14 | end 15 | ``` 16 | 17 | And fetch your project's dependencies: 18 | 19 | ```shell 20 | $ mix deps.get 21 | ``` 22 | 23 | ## Usage 24 | 25 | _TL;DR: `use AsyncWith` and just write `async` in front of `with`._ 26 | 27 | `async with` always executes the right side of each clause inside a new task. Tasks are spawned as soon as all the tasks that it depends on are resolved. In other words, `async with` resolves the dependency graph and executes all the clauses in the most performant way possible. It also ensures that, if a clause does not match, any running task is shut down. 28 | 29 | Let's start with an example: 30 | 31 | ```elixir 32 | iex> use AsyncWith 33 | iex> 34 | iex> opts = %{width: 10, height: 15} 35 | iex> async with {:ok, width} <- Map.fetch(opts, :width), 36 | ...> {:ok, height} <- Map.fetch(opts, :height) do 37 | ...> {:ok, width * height} 38 | ...> end 39 | {:ok, 150} 40 | ``` 41 | 42 | As in `with/1`, if all clauses match, the `do` block is executed, returning its result. Otherwise the chain is aborted and the non-matched value is returned: 43 | 44 | ```elixir 45 | iex> use AsyncWith 46 | iex> 47 | iex> opts = %{width: 10} 48 | iex> async with {:ok, width} <- Map.fetch(opts, :width), 49 | ...> {:ok, height} <- Map.fetch(opts, :height) do 50 | ...> {:ok, width * height} 51 | ...> end 52 | :error 53 | ``` 54 | 55 | In addition, guards can be used in patterns as well: 56 | 57 | ```elixir 58 | iex> use AsyncWith 59 | iex> 60 | iex> users = %{"melany" => "guest", "bob" => :admin} 61 | iex> async with {:ok, role} when not is_binary(role) <- Map.fetch(users, "bob") do 62 | ...> :ok 63 | ...> end 64 | :ok 65 | ``` 66 | 67 | Variables bound inside `async with` won't leak; "bare expressions" may also be inserted between the clauses: 68 | 69 | ```elixir 70 | iex> use AsyncWith 71 | iex> 72 | iex> width = nil 73 | iex> opts = %{width: 10, height: 15} 74 | iex> async with {:ok, width} <- Map.fetch(opts, :width), 75 | ...> double_width = width * 2, 76 | ...> {:ok, height} <- Map.fetch(opts, :height) do 77 | ...> {:ok, double_width * height} 78 | ...> end 79 | {:ok, 300} 80 | iex> width 81 | nil 82 | ``` 83 | 84 | An `else` option can be given to modify what is being returned from `async with` in the case of a failed match: 85 | 86 | ```elixir 87 | iex> use AsyncWith 88 | iex> 89 | iex> opts = %{width: 10} 90 | iex> async with {:ok, width} <- Map.fetch(opts, :width), 91 | ...> {:ok, height} <- Map.fetch(opts, :height) do 92 | ...> {:ok, width * height} 93 | ...> else 94 | ...> :error -> 95 | ...> {:error, :wrong_data} 96 | ...> end 97 | {:error, :wrong_data} 98 | ``` 99 | 100 | If an `else` block is used and there are no matching clauses, an `AsyncWith.ClauseError` exception is raised. 101 | 102 | Order-dependent clauses that do not express their dependency via their used or defined variables could lead to race conditions, as they are executed in separated tasks: 103 | 104 | ```elixir 105 | use AsyncWith 106 | 107 | async with Agent.update(agent, fn _ -> 1 end), 108 | Agent.update(agent, fn _ -> 2 end) do 109 | Agent.get(agent, fn state -> state end) # 1 or 2 110 | end 111 | ``` 112 | 113 | [Check the documentation](https://hexdocs.pm/async_with) for more information. 114 | 115 | ## Documentation 116 | 117 | Documentation is available at https://hexdocs.pm/async_with 118 | 119 | ## Code formatter 120 | 121 | [As described in `Code.format_string!/2` documentation](https://hexdocs.pm/elixir/Code.html#format_string!/2-parens-and-no-parens-in-function-calls), Elixir will add parens to all calls except for: 122 | 123 | 1. calls that have do/end blocks 124 | 2. local calls without parens where the name and arity of the local call is also listed under `:locals_without_parens` 125 | 126 | `async with` expressions should fall under the first category and be kept without parens, because they are similar to `with/1` calls. 127 | 128 | This is then the recommended `.formatter.exs` configuration: 129 | 130 | ```elixir 131 | [ 132 | # Regular formatter configuration 133 | # ... 134 | 135 | import_deps: [:async_with] 136 | ] 137 | ``` 138 | 139 | As an alternative, you can add `async: 1` and `async: 2` directly to the list `:locals_without_parens`. 140 | 141 | ## Contributing 142 | 143 | Bug reports and pull requests are welcome on GitHub at https://github.com/fertapric/async_with. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. 144 | 145 | ### Running tests 146 | 147 | Clone the repo and fetch its dependencies: 148 | 149 | ```shell 150 | $ git clone https://github.com/fertapric/async_with.git 151 | $ cd async_with 152 | $ mix deps.get 153 | $ mix test 154 | ``` 155 | 156 | ### Building docs 157 | 158 | ```shell 159 | $ mix docs 160 | ``` 161 | 162 | ## Acknowledgements 163 | 164 | I would like to express my gratitude to all the people in the [Elixir Core Mailing list](https://groups.google.com/forum/#!forum/elixir-lang-core) who gave ideas and feedback on the early stages of this project. A very special mention to Luke Imhoff ([@KronicDeth](https://github.com/KronicDeth)), Theron Boerner ([@hunterboerner](https://github.com/hunterboerner)), and John Wahba ([@johnwahba](https://github.com/johnwahba)). 165 | 166 | ## Copyright and License 167 | 168 | (c) Copyright 2017-2019 Fernando Tapia Rico 169 | 170 | AsyncWith source code is licensed under the [MIT License](LICENSE). 171 | -------------------------------------------------------------------------------- /lib/async_with/runner.ex: -------------------------------------------------------------------------------- 1 | defmodule AsyncWith.Runner do 2 | @moduledoc false 3 | 4 | import AsyncWith.Clauses 5 | import AsyncWith.Macro, only: [rename_ignored_vars: 1, var_map: 1] 6 | 7 | @doc """ 8 | Transforms the list of `clauses` into a format that the runner can work with. 9 | 10 | The runner expects each clause to be represented by a map with these fields: 11 | 12 | * `:function` - an anonymous function that wraps the clause so it can be 13 | executed inside a task. 14 | 15 | It must accept only one argument, a map with the values of the variables 16 | used inside the clause. For example, `%{opts: %{width: 10 }}` could be 17 | a valid argument for the clause `{:ok, width} <- Map.fetch(opts, :width)`. 18 | 19 | In case of success, it must return a triplet with `:ok`, the value 20 | returned by the execution of the right hand side of the clause and a map 21 | with the values defined in the left hand side of the clause. For example, 22 | the clause `{:ok, width} <- Map.fetch(opts, :width)` with the argument 23 | `%{opts: %{width: 10 }}` would return `{:ok, {:ok, 10}, %{width: 10}}`. 24 | 25 | In case of error, it must return `{:error, right_value}` if the sides of 26 | the clause do not match using the arrow operator `<-`; `{:nomatch, error}` 27 | if the sides of the clause do not match using the match operator `=`; 28 | `{:norescue, exception}` if the clause raises any exception; and 29 | `{:nocatch, value}` if the clause throws any value. 30 | 31 | * `:deps` - the list of variables that the clause depends on. 32 | 33 | This operation is order dependent. 34 | 35 | It's important to keep in mind that this function is executed at compile time, 36 | and that it must return a quoted expression that represents the first argument 37 | that will be passed to `run_nolink/2` at runtime. 38 | """ 39 | @spec format_clauses(Macro.t()) :: Macro.t() 40 | def format_clauses(clauses) do 41 | clauses 42 | |> format_bare_expressions() 43 | |> rename_ignored_vars() 44 | |> rename_local_vars() 45 | |> get_defined_and_used_local_vars() 46 | |> Enum.map(&format_clause/1) 47 | end 48 | 49 | defp format_clause({clause, {defined_vars, used_vars}}) do 50 | function = clause_to_function({clause, {defined_vars, used_vars}}) 51 | {:%{}, [], [function: function, deps: used_vars]} 52 | end 53 | 54 | defp clause_to_function({{operator, meta, [left, right]}, {defined_vars, used_vars}}) do 55 | quote do 56 | fn vars -> 57 | try do 58 | with unquote(var_map(used_vars)) <- vars, 59 | value <- unquote(right), 60 | unquote({operator, meta, [left, Macro.var(:value, __MODULE__)]}) do 61 | {:ok, value, unquote(var_map(defined_vars))} 62 | else 63 | error -> {:error, error} 64 | end 65 | rescue 66 | error in MatchError -> {:nomatch, error} 67 | error -> {:norescue, error} 68 | catch 69 | thrown_value -> {:nocatch, thrown_value} 70 | end 71 | end 72 | end 73 | end 74 | 75 | @doc """ 76 | Executes `run/1` in a supervised task (under `AsyncWith.TaskSupervisor`) and 77 | returns the results of the operation. 78 | 79 | The task won’t be linked to the caller, see `Task.async/3` for more 80 | information. 81 | 82 | A `timeout`, in milliseconds, must be provided to specify the maximum time 83 | allowed for this operation to complete. 84 | """ 85 | @spec run_nolink([map], non_neg_integer) :: 86 | {:ok, any} | {:error | :nomatch | :norescue | :nocatch, any} 87 | def run_nolink(clauses, timeout) do 88 | task = Task.Supervisor.async_nolink(AsyncWith.TaskSupervisor, fn -> run(clauses) end) 89 | 90 | case Task.yield(task, timeout) || Task.shutdown(task) do 91 | nil -> {:error, {:exit, {:timeout, {AsyncWith, :async, [timeout]}}}} 92 | {:ok, value} -> value 93 | error -> {:error, error} 94 | end 95 | end 96 | 97 | @doc """ 98 | Executes all the `clauses` and collects their results. 99 | 100 | Each clause is executed inside a new task. Tasks are spawned as soon as all 101 | the variables that it depends on `:deps` are resolved. It also ensures that, 102 | if a clause fails, all the running tasks are shut down. 103 | """ 104 | @spec run([map]) :: {:ok, [any]} | {:error | :nomatch | :norescue | :nocatch, any} 105 | def run(clauses) do 106 | if all_completed?(clauses) do 107 | {:ok, Enum.map(clauses, & &1.value)} 108 | else 109 | clauses 110 | |> maybe_spawn_tasks() 111 | |> await() 112 | end 113 | end 114 | 115 | defp all_completed?(clauses), do: Enum.all?(clauses, &Map.get(&1, :completed, false)) 116 | 117 | defp await(clauses) do 118 | receive do 119 | {ref, {:ok, value, vars}} -> 120 | Process.demonitor(ref, [:flush]) 121 | 122 | clauses 123 | |> assign_results_and_mark_as_completed(ref, value, vars) 124 | |> run() 125 | 126 | {_ref, error} -> 127 | shutdown_tasks(clauses) 128 | error 129 | 130 | {:DOWN, _ref, _, _, reason} -> 131 | exit(reason) 132 | end 133 | end 134 | 135 | defp maybe_spawn_tasks(clauses) do 136 | vars = Enum.reduce(clauses, %{}, &Map.merge(&2, Map.get(&1, :vars, %{}))) 137 | 138 | Enum.map(clauses, fn clause -> 139 | if spawn_task?(clause, vars) do 140 | Map.merge(clause, %{task: Task.async(fn -> clause.function.(vars) end)}) 141 | else 142 | clause 143 | end 144 | end) 145 | end 146 | 147 | defp spawn_task?(%{task: _task}, _vars), do: false 148 | defp spawn_task?(%{deps: deps}, vars), do: Enum.empty?(deps -- Map.keys(vars)) 149 | 150 | defp assign_results_and_mark_as_completed(clauses, ref, value, vars) do 151 | Enum.map(clauses, fn 152 | %{task: %Task{ref: ^ref}} = clause -> 153 | Map.merge(clause, %{value: value, vars: vars, completed: true}) 154 | 155 | clause -> 156 | clause 157 | end) 158 | end 159 | 160 | defp shutdown_tasks(clauses) do 161 | Enum.each(clauses, fn 162 | %{task: task} -> Task.shutdown(task) 163 | _ -> nil 164 | end) 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/async_with/macro.ex: -------------------------------------------------------------------------------- 1 | defmodule AsyncWith.Macro do 2 | @moduledoc false 3 | 4 | @doc """ 5 | Returns the list of variables of the `ast`. 6 | 7 | ## Examples 8 | 9 | iex> ast = 10 | ...> quote do 11 | ...> {^ok, a, b, _, _c} when is_integer(a) and is_list(b) <- echo(d, e) 12 | ...> end 13 | iex> AsyncWith.Macro.get_vars(ast) 14 | [:ok, :a, :b, :_c, :d, :e] 15 | 16 | """ 17 | @spec get_vars(Macro.t()) :: [atom] 18 | def get_vars(ast) do 19 | ast 20 | |> do_get_vars() 21 | |> Enum.uniq() 22 | end 23 | 24 | defp do_get_vars({:_, _meta, _context}), do: [] 25 | defp do_get_vars({:binary, _meta, _context}), do: [] 26 | defp do_get_vars({var, _meta, context}) when is_atom(var) and is_atom(context), do: [var] 27 | defp do_get_vars(ast) when is_list(ast), do: Enum.flat_map(ast, &do_get_vars/1) 28 | defp do_get_vars(ast) when is_tuple(ast), do: do_get_vars(Tuple.to_list(ast)) 29 | defp do_get_vars(_ast), do: [] 30 | 31 | @doc """ 32 | Returns the list of pinned variables (`^var`) of the `ast`. 33 | 34 | ## Examples 35 | 36 | iex> ast = 37 | ...> quote do 38 | ...> {^ok, a, b, _} when is_integer(a) and is_list(b) <- echo(c, d) 39 | ...> end 40 | iex> AsyncWith.Macro.get_pinned_vars(ast) 41 | [:ok] 42 | 43 | """ 44 | @spec get_pinned_vars(Macro.t()) :: [atom] 45 | def get_pinned_vars(ast) do 46 | ast 47 | |> do_get_pinned_vars() 48 | |> Enum.uniq() 49 | end 50 | 51 | defp do_get_pinned_vars({:^, _meta, args}), do: get_vars(args) 52 | defp do_get_pinned_vars(ast) when is_list(ast), do: Enum.flat_map(ast, &do_get_pinned_vars/1) 53 | defp do_get_pinned_vars(ast) when is_tuple(ast), do: do_get_pinned_vars(Tuple.to_list(ast)) 54 | defp do_get_pinned_vars(_ast), do: [] 55 | 56 | @doc """ 57 | Returns the list of variables used in the guard clauses of the `ast`. 58 | 59 | ## Examples 60 | 61 | iex> ast = 62 | ...> quote do 63 | ...> {^ok, a, b, _} when is_integer(a) and is_list(b) <- echo(c, d) 64 | ...> end 65 | iex> AsyncWith.Macro.get_guard_vars(ast) 66 | [:a, :b] 67 | 68 | """ 69 | @spec get_guard_vars(Macro.t()) :: [atom] 70 | def get_guard_vars(ast) do 71 | ast 72 | |> do_get_guard_vars() 73 | |> Enum.uniq() 74 | end 75 | 76 | defp do_get_guard_vars({:when, _meta, [_left, right]}), do: get_vars(right) 77 | defp do_get_guard_vars(ast) when is_list(ast), do: Enum.flat_map(ast, &do_get_guard_vars/1) 78 | defp do_get_guard_vars(ast) when is_tuple(ast), do: do_get_guard_vars(Tuple.to_list(ast)) 79 | defp do_get_guard_vars(_ast), do: [] 80 | 81 | @doc """ 82 | Returns true if the `ast` represents a variable. 83 | 84 | ## Examples 85 | 86 | iex> ast = quote(do: a) 87 | iex> AsyncWith.Macro.var?(ast) 88 | true 89 | 90 | iex> ast = quote(do: {:ok, 1}) 91 | iex> AsyncWith.Macro.var?(ast) 92 | false 93 | 94 | """ 95 | @spec var?(Macro.t()) :: boolean 96 | def var?(ast) 97 | def var?({:_, _meta, _context}), do: false 98 | def var?({:binary, _meta, _context}), do: false 99 | def var?({var, _meta, context}) when is_atom(var) and is_atom(context), do: true 100 | def var?(_ast), do: false 101 | 102 | @doc ~S""" 103 | Returns an AST node where each variable is replaced by the result of invoking 104 | `function` on that variable. 105 | 106 | ## Examples 107 | 108 | iex> ast = quote(do: [^a, {1, %{b: c}, [2, d], [e: ^f]}, _]) 109 | iex> fun = fn {var, meta, context} -> {:"var_#{var}", meta, context} end 110 | iex> AsyncWith.Macro.map_vars(ast, fun) |> Macro.to_string() 111 | "[^var_a, {1, %{b: var_c}, [2, var_d], [e: ^var_f]}, _]" 112 | 113 | """ 114 | @spec map_vars(Macro.t(), function) :: Macro.t() 115 | def map_vars(ast, function) 116 | def map_vars({:_, _meta, _context} = ast, _fun), do: ast 117 | def map_vars({:binary, _meta, _context} = ast, _fun), do: ast 118 | def map_vars({var, _, context} = ast, fun) when is_atom(var) and is_atom(context), do: fun.(ast) 119 | def map_vars(ast, fun) when is_list(ast), do: Enum.map(ast, &map_vars(&1, fun)) 120 | def map_vars(ast, fun) when is_tuple(ast), do: tuple_map(ast, &map_vars(&1, fun)) 121 | def map_vars(ast, _fun), do: ast 122 | 123 | @doc ~S""" 124 | Returns an AST node where each pinned variable (`^var`) is replaced by the 125 | result of invoking `function` on that variable. 126 | 127 | ## Examples 128 | 129 | iex> ast = quote(do: [^a, {1, %{b: c}, [2, d], [e: ^f]}, _]) 130 | iex> fun = fn {var, meta, context} -> {:"var_#{var}", meta, context} end 131 | iex> AsyncWith.Macro.map_pinned_vars(ast, fun) |> Macro.to_string() 132 | "[^var_a, {1, %{b: c}, [2, d], [e: ^var_f]}, _]" 133 | 134 | """ 135 | @spec map_pinned_vars(Macro.t(), function) :: Macro.t() 136 | def map_pinned_vars(ast, function) 137 | def map_pinned_vars({:^, meta, args}, fun), do: {:^, meta, map_vars(args, fun)} 138 | def map_pinned_vars(ast, fun) when is_list(ast), do: Enum.map(ast, &map_pinned_vars(&1, fun)) 139 | def map_pinned_vars(ast, fun) when is_tuple(ast), do: tuple_map(ast, &map_pinned_vars(&1, fun)) 140 | def map_pinned_vars(ast, _fun), do: ast 141 | 142 | @doc """ 143 | Renames the variables in `ast`. 144 | 145 | ## Examples 146 | 147 | iex> ast = quote(do: [^a, {1, %{b: c}, [2, d], [e: f]}]) 148 | iex> var_renamings = %{a: :foo, b: :wadus, c: :bar, f: :qux} 149 | iex> AsyncWith.Macro.rename_vars(ast, var_renamings) |> Macro.to_string() 150 | "[^foo, {1, %{b: bar}, [2, d], [e: qux]}]" 151 | 152 | """ 153 | @spec rename_vars(Macro.t(), map) :: Macro.t() 154 | def rename_vars(ast, var_renamings) do 155 | map_vars(ast, fn {var, meta, context} -> 156 | {Map.get(var_renamings, var, var), meta, context} 157 | end) 158 | end 159 | 160 | @doc """ 161 | Renames the pinned variables (`^var`) in `ast`. 162 | 163 | ## Examples 164 | 165 | iex> ast = quote(do: [^a, {1, %{b: c}, [2, d], [e: ^f]}]) 166 | iex> var_renamings = %{a: :foo, c: :bar, f: :qux} 167 | iex> AsyncWith.Macro.rename_pinned_vars(ast, var_renamings) 168 | ...> |> Macro.to_string() 169 | "[^foo, {1, %{b: c}, [2, d], [e: ^qux]}]" 170 | 171 | """ 172 | @spec rename_pinned_vars(Macro.t(), map) :: Macro.t() 173 | def rename_pinned_vars(ast, var_renamings) do 174 | map_pinned_vars(ast, fn {var, meta, context} -> 175 | {Map.get(var_renamings, var, var), meta, context} 176 | end) 177 | end 178 | 179 | @doc """ 180 | Renames the ignored variables (`_var`) in `ast`. 181 | 182 | Variables are renamed by appending `@`. 183 | 184 | ## Examples 185 | 186 | iex> ast = quote(do: [^a, {1, %{b: c}, [2, _d], [e: ^f], _}]) 187 | iex> AsyncWith.Macro.rename_ignored_vars(ast) |> Macro.to_string() 188 | "[^a, {1, %{b: c}, [2, @_d], [e: ^f], _}]" 189 | 190 | """ 191 | @spec rename_ignored_vars(Macro.t()) :: Macro.t() 192 | def rename_ignored_vars(ast) do 193 | map_vars(ast, fn {var, meta, context} -> 194 | case to_string(var) do 195 | "_" <> _ -> {:"@#{var}", meta, context} 196 | _ -> {var, meta, context} 197 | end 198 | end) 199 | end 200 | 201 | @doc """ 202 | Generates an AST node representing the list of variables given by the atoms 203 | `vars` and `context`. 204 | 205 | ## Examples 206 | 207 | iex> vars = [:a, :b, :c] 208 | iex> AsyncWith.Macro.var_list(vars) |> Macro.to_string() 209 | "[a, b, c]" 210 | 211 | """ 212 | @spec var_list([atom], atom) :: Macro.t() 213 | def var_list(vars, context \\ nil) when is_list(vars) and is_atom(context) do 214 | Enum.map(vars, &Macro.var(&1, context)) 215 | end 216 | 217 | @doc """ 218 | Generates an AST node representing a map of variables given by the atoms 219 | `vars` and `context`. 220 | 221 | ## Examples 222 | 223 | iex> vars = [:a, :b, :c] 224 | iex> AsyncWith.Macro.var_map(vars) |> Macro.to_string() 225 | "%{a: a, b: b, c: c}" 226 | 227 | """ 228 | @spec var_map([atom], atom) :: Macro.t() 229 | def var_map(vars, context \\ nil) when is_list(vars) and is_atom(context) do 230 | {:%{}, [], Enum.map(vars, fn var -> {var, Macro.var(var, context)} end)} 231 | end 232 | 233 | defp tuple_map(tuple, fun) do 234 | tuple 235 | |> Tuple.to_list() 236 | |> Enum.map(&fun.(&1)) 237 | |> List.to_tuple() 238 | end 239 | end 240 | -------------------------------------------------------------------------------- /lib/async_with/clauses.ex: -------------------------------------------------------------------------------- 1 | defmodule AsyncWith.Clauses do 2 | @moduledoc false 3 | 4 | defmodule Clause do 5 | @moduledoc false 6 | 7 | import AsyncWith.Macro, 8 | only: [get_pinned_vars: 1, get_vars: 1, rename_pinned_vars: 2, rename_vars: 2, var?: 1] 9 | 10 | @doc """ 11 | Returns true if the clause will always match. 12 | 13 | ## Examples 14 | 15 | iex> ast = quote(do: a <- 1) 16 | iex> Clause.always_match?(ast) 17 | true 18 | 19 | iex> [ast] = quote(do: (a -> 1)) 20 | iex> Clause.always_match?(ast) 21 | true 22 | 23 | iex> ast = quote(do: _ <- 1) 24 | iex> Clause.always_match?(ast) 25 | true 26 | 27 | iex> [ast] = quote(do: (_ -> 1)) 28 | iex> Clause.always_match?(ast) 29 | true 30 | 31 | iex> ast = quote(do: a = 1) 32 | iex> Clause.always_match?(ast) 33 | true 34 | 35 | iex> ast = quote(do: a + b) 36 | iex> Clause.always_match?(ast) 37 | true 38 | 39 | iex> ast = quote(do: {:ok, a} <- b + 1) 40 | iex> Clause.always_match?(ast) 41 | false 42 | 43 | iex> [ast] = quote(do: ({:ok, b} -> b + 1)) 44 | iex> Clause.always_match?(ast) 45 | false 46 | 47 | """ 48 | @spec always_match?(Macro.t()) :: boolean 49 | def always_match?({:<-, _meta, [{:_, _, _context}, _right]}), do: true 50 | def always_match?({:<-, _meta, [left, _right]}), do: var?(left) 51 | def always_match?({:->, _meta, [[{:_, _, _context}], _right]}), do: true 52 | def always_match?({:->, _meta, [[left], _right]}), do: var?(left) 53 | def always_match?(_), do: true 54 | 55 | @doc """ 56 | Returns the list of used variables in the clause. 57 | 58 | Used variables are the ones at the right hand side of the clause or pinned 59 | variables at the left hand side of the clause. 60 | 61 | ## Examples 62 | 63 | iex> ast = 64 | ...> quote do 65 | ...> {^ok, a, b, _, _c} when is_integer(a) and b > 0 <- echo(d, e) 66 | ...> end 67 | iex> Clause.get_used_vars(ast) 68 | [:ok, :d, :e] 69 | 70 | """ 71 | @spec get_used_vars(Macro.t()) :: Macro.t() 72 | def get_used_vars({_operator, _meta, [left, right]}) do 73 | Enum.uniq(get_pinned_vars(left) ++ get_vars(right)) 74 | end 75 | 76 | @doc """ 77 | Returns the list of defined variables in the clause. 78 | 79 | Defined variables are the ones binded at the left hand side of the clause. 80 | 81 | ## Examples 82 | 83 | iex> ast = 84 | ...> quote do 85 | ...> {^ok, a, b, _, _c} when is_integer(a) and b > 0 <- echo(d, e) 86 | ...> end 87 | iex> Clause.get_defined_vars(ast) 88 | [:a, :b, :_c] 89 | 90 | """ 91 | @spec get_defined_vars(Macro.t()) :: Macro.t() 92 | def get_defined_vars({_operator, _meta, [left, _right]}) do 93 | get_vars(left) -- get_pinned_vars(left) 94 | end 95 | 96 | @doc """ 97 | Renames the used variables in the clause. 98 | 99 | Used variables are the ones at the right hand side of the clause or pinned 100 | variables at the left hand side of the clause. 101 | 102 | ## Examples 103 | 104 | iex> ast = quote(do: {^ok, a} when is_integer(a) <- b + c) 105 | iex> var_renamings = %{ok: :new_ok, b: :new_b, a: :new_a} 106 | iex> Clause.rename_used_vars(ast, var_renamings) |> Macro.to_string() 107 | "{^new_ok, a} when is_integer(a) <- new_b + c" 108 | 109 | """ 110 | @spec rename_used_vars(Macro.t(), map) :: Macro.t() 111 | def rename_used_vars({operator, meta, [left, right]}, var_renamings) do 112 | renamed_left = rename_pinned_vars(left, var_renamings) 113 | renamed_right = rename_vars(right, var_renamings) 114 | 115 | {operator, meta, [renamed_left, renamed_right]} 116 | end 117 | 118 | @doc """ 119 | Renames the defined (or binded) variables in the clause. 120 | 121 | Defined variables are the ones binded at the left hand side of the clause. 122 | 123 | ## Examples 124 | 125 | iex> ast = quote(do: {^ok, a, b} when is_integer(a)<- c + d) 126 | iex> var_renamings = %{ok: :new_ok, a: :new_a, c: :new_c} 127 | iex> Clause.rename_defined_vars(ast, var_renamings) |> Macro.to_string() 128 | "{^ok, new_a, b} when is_integer(new_a) <- c + d" 129 | 130 | """ 131 | @spec rename_defined_vars(Macro.t(), map) :: Macro.t() 132 | def rename_defined_vars({operator, meta, [left, right]} = clause, var_renamings) do 133 | defined_vars = get_defined_vars(clause) 134 | renamed_left = rename_vars(left, Map.take(var_renamings, defined_vars)) 135 | 136 | {operator, meta, [renamed_left, right]} 137 | end 138 | end 139 | 140 | @doc """ 141 | Returns true if all patterns in `clauses` will always match. 142 | 143 | ## Examples 144 | 145 | iex> ast = quote(do: [a <- 1, b <- 2, _ <- c, {:ok, d} = echo(c)]) 146 | iex> Clauses.always_match?(ast) 147 | true 148 | 149 | iex> ast = quote(do: [a <- 1, {:ok, b} <- echo(a), {:ok, c} = echo(b)]) 150 | iex> Clauses.always_match?(ast) 151 | false 152 | 153 | """ 154 | @spec always_match?(Macro.t()) :: boolean 155 | def always_match?(clauses) do 156 | Enum.all?(clauses, &Clause.always_match?/1) 157 | end 158 | 159 | @doc """ 160 | Returns true if `clauses` contain a match-all clause. 161 | 162 | This operation can be used to prevent messages like `warning: this clause 163 | cannot match because a previous clause at line always matches`. 164 | 165 | ## Examples 166 | 167 | iex> ast = 168 | ...> quote do 169 | ...> :error -> :error 170 | ...> error -> error 171 | ...> end 172 | iex> Clauses.contains_match_all_clause?(ast) 173 | true 174 | 175 | iex> ast = 176 | ...> quote do 177 | ...> :error -> :error 178 | ...> _ -> nil 179 | ...> end 180 | iex> Clauses.contains_match_all_clause?(ast) 181 | true 182 | 183 | iex> ast = 184 | ...> quote do 185 | ...> :error -> :error 186 | ...> :ok -> :ok 187 | ...> end 188 | iex> Clauses.contains_match_all_clause?(ast) 189 | false 190 | 191 | """ 192 | @spec contains_match_all_clause?(Macro.t()) :: boolean 193 | def contains_match_all_clause?(clauses) do 194 | Enum.any?(clauses, &Clause.always_match?/1) 195 | end 196 | 197 | @doc """ 198 | Formats the list of `clauses`, converting any "bare expression" 199 | into an assignment (`_ = expression`). 200 | 201 | This operation can be used to normalize clauses, so they are always composed 202 | of three parts: `left <- right` or `left = right`. 203 | 204 | ## Examples 205 | 206 | iex> ast = quote(do: [a <- 1, b = 2, a + b]) 207 | iex> Clauses.format_bare_expressions(ast) |> Macro.to_string() 208 | "[a <- 1, b = 2, _ = a + b]" 209 | 210 | """ 211 | @spec format_bare_expressions(Macro.t()) :: Macro.t() 212 | def format_bare_expressions(clauses) do 213 | Enum.map(clauses, fn 214 | {:<-, _meta, _args} = clause -> clause 215 | {:=, _meta, _args} = clause -> clause 216 | clause -> {:=, [], [Macro.var(:_, __MODULE__), clause]} 217 | end) 218 | end 219 | 220 | @doc """ 221 | Returns the list of local variables that are used and defined per clause. 222 | 223 | Used variables are the ones at the right hand side of the clause or pinned 224 | variables at the left hand side of the clause. 225 | 226 | Defined variables are the ones binded at the left hand side of the clause. 227 | 228 | Local variables are the ones defined in previous clauses, any other variables 229 | are considered external. 230 | 231 | It returns `{clause, {defined_vars, used_vars}}` per clause. 232 | 233 | This operation is order dependent. 234 | 235 | ## Examples 236 | 237 | iex> ast = 238 | ...> quote do 239 | ...> [ 240 | ...> {:ok, a} when is_integer(a) <- echo(b, c), 241 | ...> {^ok, c, d} <- a + e, 242 | ...> {^d, f, g} <- a + b + c 243 | ...> ] 244 | ...> end 245 | iex> Clauses.get_defined_and_used_local_vars(ast) 246 | quote do 247 | [ 248 | { 249 | {:ok, a} when is_integer(a) <- echo(b, c), 250 | {[:a], []} 251 | }, 252 | { 253 | {^ok, c, d} <- a + e, 254 | {[:c, :d], [:a]} 255 | }, 256 | { 257 | {^d, f, g} <- a + b + c, 258 | {[:f, :g], [:d, :a, :c]} 259 | } 260 | ] 261 | end 262 | 263 | """ 264 | @spec get_defined_and_used_local_vars(Macro.t()) :: Macro.t() 265 | def get_defined_and_used_local_vars(clauses) do 266 | {clauses, _local_vars} = 267 | Enum.map_reduce(clauses, [], fn clause, local_vars -> 268 | defined_vars = Clause.get_defined_vars(clause) 269 | used_vars = Clause.get_used_vars(clause) 270 | external_vars = used_vars -- local_vars 271 | used_local_vars = used_vars -- external_vars 272 | local_vars = Enum.uniq(local_vars ++ defined_vars) 273 | 274 | {{clause, {defined_vars, used_local_vars}}, local_vars} 275 | end) 276 | 277 | clauses 278 | end 279 | 280 | @doc """ 281 | Renames all the variables that are defined locally. 282 | 283 | Local variables are the ones defined in previous clauses, any other variables 284 | are considered external. 285 | 286 | Variables are renamed by appending `@` and the variable "version" to its name 287 | (i.e. `var@1`). 288 | 289 | This operation can be used to obtain unique variable names, which can be helpful 290 | in cases of variable rebinding. 291 | 292 | This operation is order dependent. 293 | 294 | ## Examples 295 | 296 | iex> ast = 297 | ...> quote do 298 | ...> [ 299 | ...> {:ok, a} <- echo(b, c), 300 | ...> {^ok, b, a} when is_integer(a) <- foo(a, b), 301 | ...> {^b, c, d} <- bar(a, b, c), 302 | ...> {:ok, d, b, a} when is_integer(a) <- baz(a, b, c, d) 303 | ...> ] 304 | ...> end 305 | iex> Clauses.rename_local_vars(ast) |> Enum.map(&Macro.to_string/1) 306 | [ 307 | "{:ok, a@1} <- echo(b, c)", 308 | "{^ok, b@1, a@2} when is_integer(a@2) <- foo(a@1, b)", 309 | "{^b@1, c@1, d@1} <- bar(a@2, b@1, c)", 310 | "{:ok, d@2, b@2, a@3} when is_integer(a@3) <- baz(a@2, b@1, c@1, d@1)" 311 | ] 312 | 313 | """ 314 | @spec rename_local_vars(Macro.t()) :: Macro.t() 315 | def rename_local_vars(clauses) do 316 | {clauses, _var_versions} = 317 | Enum.map_reduce(clauses, %{}, fn clause, var_versions -> 318 | clause = Clause.rename_used_vars(clause, var_versions_to_var_renamings(var_versions)) 319 | 320 | var_versions = increase_var_versions(var_versions, Clause.get_defined_vars(clause)) 321 | clause = Clause.rename_defined_vars(clause, var_versions_to_var_renamings(var_versions)) 322 | 323 | {clause, var_versions} 324 | end) 325 | 326 | clauses 327 | end 328 | 329 | defp increase_var_versions(var_versions, vars) do 330 | Enum.reduce(vars, var_versions, fn var, var_versions -> 331 | Map.update(var_versions, var, 1, &(&1 + 1)) 332 | end) 333 | end 334 | 335 | defp var_versions_to_var_renamings(var_versions) do 336 | for {var, version} <- var_versions, do: {var, :"#{var}@#{version}"}, into: %{} 337 | end 338 | end 339 | -------------------------------------------------------------------------------- /lib/async_with.ex: -------------------------------------------------------------------------------- 1 | defmodule AsyncWith do 2 | @moduledoc ~S""" 3 | The asynchronous version of Elixir's `with`. 4 | 5 | `async with` always executes the right side of each clause inside a new task. 6 | Tasks are spawned as soon as all the tasks that it depends on are resolved. 7 | In other words, `async with` resolves the dependency graph and executes all 8 | the clauses in the most performant way possible. It also ensures that, if a 9 | clause does not match, any running task is shut down. 10 | 11 | ## Example 12 | 13 | defmodule AcmeWeb.PostController do 14 | use AcmeWeb, :controller 15 | use AsyncWith 16 | 17 | def show(conn, %{"id" => id}) do 18 | async with {:ok, post} <- Blog.get_post(id), 19 | {:ok, author} <- Users.get_user(post.author_id), 20 | {:ok, posts_by_the_same_author} <- Blog.get_posts(author), 21 | {:ok, similar_posts} <- Blog.get_similar_posts(post), 22 | {:ok, comments} <- Blog.list_comments(post), 23 | {:ok, comments} <- Blog.preload(comments, :author) do 24 | conn 25 | |> assign(:post, post) 26 | |> assign(:author, author) 27 | |> assign(:posts_by_the_same_author, posts_by_the_same_author) 28 | |> assign(:similar_posts, similar_posts) 29 | |> assign(:comments, comments) 30 | |> render("show.html") 31 | end 32 | end 33 | end 34 | 35 | ## Timeout attribute 36 | 37 | The attribute `@async_with_timeout` can be used to configure the maximum time 38 | allowed to execute all the clauses. It expects a timeout in milliseconds, with 39 | the default value of `5000`. 40 | 41 | defmodule Acme do 42 | use AsyncWith 43 | 44 | @async_with_timeout 1_000 45 | 46 | def get_user_info(user_id) do 47 | async with {:ok, user} <- HTTP.get("users/#{user_id}"), 48 | {:ok, stats} <- HTTP.get("users/#{user_id}/stats") 49 | Map.merge(user, %{stats: stats}) 50 | end 51 | end 52 | end 53 | 54 | """ 55 | 56 | alias AsyncWith.Clauses 57 | alias AsyncWith.Runner 58 | 59 | @default_timeout 5_000 60 | 61 | defmacro __using__(_) do 62 | # Module attributes can only be defined inside a module. 63 | # This allows to `use AsyncWith` inside an interactive IEx session. 64 | timeout = 65 | if __CALLER__.module do 66 | quote do 67 | @async_with_timeout unquote(@default_timeout) 68 | end 69 | end 70 | 71 | quote do 72 | import unquote(__MODULE__), only: [async: 1, async: 2] 73 | 74 | unquote(timeout) 75 | end 76 | end 77 | 78 | @doc """ 79 | Used to combine matching clauses, executing them asynchronously. 80 | 81 | `async with` always executes the right side of each clause inside a new task. 82 | Tasks are spawned as soon as all the tasks that it depends on are resolved. 83 | In other words, `async with` resolves the dependency graph and executes all 84 | the clauses in the most performant way possible. It also ensures that, if a 85 | clause does not match, any running task is shut down. 86 | 87 | Let's start with an example: 88 | 89 | iex> opts = %{width: 10, height: 15} 90 | iex> async with {:ok, width} <- Map.fetch(opts, :width), 91 | ...> {:ok, height} <- Map.fetch(opts, :height) do 92 | ...> {:ok, width * height} 93 | ...> end 94 | {:ok, 150} 95 | 96 | As in `with/1`, if all clauses match, the `do` block is executed, returning its 97 | result. Otherwise the chain is aborted and the non-matched value is returned: 98 | 99 | iex> opts = %{width: 10} 100 | iex> async with {:ok, width} <- Map.fetch(opts, :width), 101 | ...> {:ok, height} <- Map.fetch(opts, :height) do 102 | ...> {:ok, width * height} 103 | ...> end 104 | :error 105 | 106 | In addition, guards can be used in patterns as well: 107 | 108 | iex> users = %{"melany" => "guest", "ed" => :admin} 109 | iex> async with {:ok, role} when is_atom(role) <- Map.fetch(users, "ed") do 110 | ...> :ok 111 | ...> end 112 | :ok 113 | 114 | Variables bound inside `async with` won't leak; "bare expressions" may also 115 | be inserted between the clauses: 116 | 117 | iex> width = nil 118 | iex> opts = %{width: 10, height: 15} 119 | iex> async with {:ok, width} <- Map.fetch(opts, :width), 120 | ...> double_width = width * 2, 121 | ...> {:ok, height} <- Map.fetch(opts, :height) do 122 | ...> {:ok, double_width * height} 123 | ...> end 124 | {:ok, 300} 125 | iex> width 126 | nil 127 | 128 | An `else` option can be given to modify what is being returned from 129 | `async with` in the case of a failed match: 130 | 131 | iex> opts = %{width: 10} 132 | iex> async with {:ok, width} <- Map.fetch(opts, :width), 133 | ...> {:ok, height} <- Map.fetch(opts, :height) do 134 | ...> {:ok, width * height} 135 | ...> else 136 | ...> :error -> 137 | ...> {:error, :wrong_data} 138 | ...> end 139 | {:error, :wrong_data} 140 | 141 | If an `else` block is used and there are no matching clauses, an 142 | `AsyncWith.ClauseError` exception is raised. 143 | 144 | Order-dependent clauses that do not express their dependency via their used or 145 | defined variables could lead to race conditions, as they are executed in 146 | separated tasks: 147 | 148 | async with Agent.update(agent, fn _ -> 1 end), 149 | Agent.update(agent, fn _ -> 2 end) do 150 | Agent.get(agent, fn state -> state end) # 1 or 2 151 | end 152 | 153 | """ 154 | defmacro async(with_expression, blocks \\ []) 155 | 156 | # `async with` with no arguments. 157 | # 158 | # Example: 159 | # 160 | # async with do 161 | # 1 162 | # end 163 | # 164 | defmacro async({:with, _meta, args}, do: do_block, else: _else_block) when not is_list(args) do 165 | warn_else_clauses_will_never_match(__CALLER__) 166 | quote(do: with(do: unquote(do_block))) 167 | end 168 | 169 | defmacro async({:with, _meta, args}, do: do_block) when not is_list(args) do 170 | quote(do: with(do: unquote(do_block))) 171 | end 172 | 173 | # `async with` with :do and :else blocks. 174 | # 175 | # Example: 176 | # 177 | # async with a <- function(), 178 | # b <- function(a) do 179 | # {a, b} 180 | # else 181 | # error -> error 182 | # end 183 | # 184 | defmacro async({:with, _meta, clauses}, do: do_block, else: else_block) do 185 | if Clauses.always_match?(clauses), do: warn_else_clauses_will_never_match(__CALLER__) 186 | do_async(__CALLER__.module, clauses, do: do_block, else: else_block) 187 | end 188 | 189 | defmacro async({:with, _meta, clauses}, do: do_block) do 190 | do_async(__CALLER__.module, clauses, do: do_block, else: quote(do: (error -> error))) 191 | end 192 | 193 | # `async with` with :do and :else options (single line). 194 | # 195 | # Example: 196 | # 197 | # async with a <- function(), 198 | # b <- function(a), 199 | # do: {a, b} 200 | # 201 | defmacro async({:with, _meta, args}, _) when is_list(args) do 202 | case List.last(args) do 203 | [do: do_block, else: else_block] -> 204 | clauses = List.delete_at(args, -1) 205 | if Clauses.always_match?(clauses), do: warn_else_clauses_will_never_match(__CALLER__) 206 | do_async(__CALLER__.module, clauses, do: do_block, else: else_block) 207 | 208 | [do: do_block] -> 209 | clauses = List.delete_at(args, -1) 210 | do_async(__CALLER__.module, clauses, do: do_block, else: quote(do: (error -> error))) 211 | 212 | _ -> 213 | message = ~s(missing :do option in "async with") 214 | raise(CompileError, file: __CALLER__.file, line: __CALLER__.line, description: message) 215 | end 216 | end 217 | 218 | defmacro async({:with, _meta, _args}, _) do 219 | message = ~s(missing :do option in "async with") 220 | raise(CompileError, file: __CALLER__.file, line: __CALLER__.line, description: message) 221 | end 222 | 223 | defmacro async(_, _) do 224 | message = ~s("async" macro must be used with "with") 225 | raise(CompileError, file: __CALLER__.file, line: __CALLER__.line, description: message) 226 | end 227 | 228 | defp do_async(module, clauses, do: do_block, else: else_block) do 229 | # Module attributes can only be defined inside a module. 230 | # This allows to `use AsyncWith` inside an interactive IEx session. 231 | timeout = if module, do: quote(do: @async_with_timeout), else: @default_timeout 232 | 233 | quote do 234 | case Runner.run_nolink(unquote(Runner.format_clauses(clauses)), unquote(timeout)) do 235 | {:ok, values} -> 236 | with unquote_splicing(change_right_hand_side_of_clauses_to_read_from_values(clauses)) do 237 | unquote(do_block) 238 | end 239 | 240 | {:nomatch, %MatchError{term: term}} -> 241 | raise(MatchError, term: term) 242 | 243 | {:norescue, error} -> 244 | raise(error) 245 | 246 | {:nocatch, thrown_value} -> 247 | throw(thrown_value) 248 | 249 | {:error, error} -> 250 | case error, do: unquote(maybe_change_else_block_to_raise_clause_error(else_block)) 251 | end 252 | end 253 | end 254 | 255 | # Prints a warning message saying that "else" clauses will never match 256 | # because all patterns in "async with" will always match. 257 | # 258 | # This mimics `with/1` behavior. 259 | defp warn_else_clauses_will_never_match(caller) do 260 | message = 261 | ~s("else" clauses will never match because all patterns in "async with" will always match) 262 | 263 | IO.warn(message, Macro.Env.stacktrace(caller)) 264 | end 265 | 266 | # Changes the `else_block` to raise `AsyncWith.ClauseError` if none of the 267 | # "else" clauses match. 268 | defp maybe_change_else_block_to_raise_clause_error(else_block) do 269 | if Clauses.contains_match_all_clause?(else_block) do 270 | else_block 271 | else 272 | else_block ++ quote(do: (term -> raise(AsyncWith.ClauseError, term: term))) 273 | end 274 | end 275 | 276 | # Changes the right hand side of each clause to read from the `values` 277 | # variable. 278 | # 279 | # Keeping the left hand side prevents warning messages with variables only 280 | # used in guards: `warning: variable "" is unused`. 281 | # 282 | # async with {:ok, level} when level > 4 <- get_security_level(user_id), 283 | # {:ok, data} <- read_secret_data() do 284 | # {:ok, data} 285 | # end 286 | # 287 | defp change_right_hand_side_of_clauses_to_read_from_values(clauses) do 288 | {clauses, _index} = 289 | clauses 290 | |> Clauses.format_bare_expressions() 291 | |> Clauses.get_defined_and_used_local_vars() 292 | |> Enum.map_reduce(0, fn {{operator, meta, [left, _]}, {_defined_vars, used_vars}}, index -> 293 | # Used variables are passed as the third argument to prevent warning messages 294 | # with temporary variables: `warning: variable "" is unused`. 295 | # 296 | # The variable `width` is an example of a temporary variable: 297 | # 298 | # async with {:ok, width} <- {:ok, 10}, 299 | # double_width = width * 2 do 300 | # {:ok, double_width} 301 | # end 302 | # 303 | right = 304 | quote do 305 | Enum.at(values, unquote(index), unquote(AsyncWith.Macro.var_list(used_vars))) 306 | end 307 | 308 | {{operator, meta, [left, right]}, index + 1} 309 | end) 310 | 311 | clauses 312 | end 313 | end 314 | -------------------------------------------------------------------------------- /test/async_with_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AsyncWithTest do 2 | use ExUnit.Case 3 | use AsyncWith 4 | 5 | import ExUnit.CaptureIO 6 | 7 | @async_with_timeout 50 8 | 9 | doctest AsyncWith 10 | 11 | test "raises a CompileError error if 'async' is not followed by 'with'" do 12 | assert_raise CompileError, ~r/"async" macro must be used with "with"/, fn -> 13 | ast = 14 | quote do 15 | async a <- 1 do 16 | a 17 | end 18 | end 19 | 20 | Code.eval_quoted(ast) 21 | end 22 | end 23 | 24 | test "raises a CompileError error if 'async' is not followed by 'with' (without clauses)" do 25 | assert_raise CompileError, ~r/"async" macro must be used with "with"/, fn -> 26 | ast = 27 | quote do 28 | async do 29 | 2 30 | end 31 | end 32 | 33 | Code.eval_quoted(ast) 34 | end 35 | end 36 | 37 | test "raises a CompileError error if 'async' is not followed by 'with' (without :do block)" do 38 | assert_raise CompileError, ~r/"async" macro must be used with "with"/, fn -> 39 | ast = 40 | quote do 41 | async a <- 1 42 | end 43 | 44 | Code.eval_quoted(ast) 45 | end 46 | end 47 | 48 | test "raises a CompileError error if 'async' is not followed by 'with' (single line)" do 49 | assert_raise CompileError, ~r/"async" macro must be used with "with"/, fn -> 50 | ast = 51 | quote do 52 | async a <- 1, do: a 53 | end 54 | 55 | Code.eval_quoted(ast) 56 | end 57 | end 58 | 59 | test "raises a CompileError error if :do option is missing" do 60 | assert_raise CompileError, ~r/missing :do option in "async with"/, fn -> 61 | ast = 62 | quote do 63 | async with a <- 1 64 | end 65 | 66 | Code.eval_quoted(ast) 67 | end 68 | end 69 | 70 | test "raises a CompileError error if :do option is missing (without clauses)" do 71 | assert_raise CompileError, ~r/missing :do option in "async with"/, fn -> 72 | ast = 73 | quote do 74 | async with 75 | end 76 | 77 | Code.eval_quoted(ast) 78 | end 79 | end 80 | 81 | test "emits a warning if 'else' clauses will never match" do 82 | expexted_message = 83 | ~s("else" clauses will never match because all patterns in "async with" will always match) 84 | 85 | unexpected_message = 86 | ~s("else" clauses will never match because all patterns in "with" will always match) 87 | 88 | message = 89 | capture_io(:stderr, fn -> 90 | string = """ 91 | defmodule AsyncWithTest.A do 92 | use AsyncWith 93 | 94 | def test do 95 | async with a <- 1, b = 2 do 96 | a + b 97 | else 98 | :error -> :error 99 | end 100 | end 101 | end 102 | """ 103 | 104 | Code.eval_string(string) 105 | end) 106 | 107 | assert warnings_count(message) == 1 108 | assert message =~ expexted_message 109 | refute message =~ unexpected_message 110 | end 111 | 112 | test "emits a warning if 'else' clauses will never match (single line)" do 113 | expexted_message = 114 | ~s("else" clauses will never match because all patterns in "async with" will always match) 115 | 116 | unexpected_message = 117 | ~s("else" clauses will never match because all patterns in "with" will always match) 118 | 119 | message = 120 | capture_io(:stderr, fn -> 121 | string = """ 122 | defmodule AsyncWithTest.B do 123 | use AsyncWith 124 | 125 | def test do 126 | async with a <- 1, b = 2, do: a + b, else: (:error -> :error) 127 | end 128 | end 129 | """ 130 | 131 | Code.eval_string(string) 132 | end) 133 | 134 | assert warnings_count(message) == 1 135 | assert message =~ expexted_message 136 | refute message =~ unexpected_message 137 | end 138 | 139 | test "emits a warning if 'else' clauses will never match (without clauses)" do 140 | expexted_message = 141 | ~s("else" clauses will never match because all patterns in "async with" will always match) 142 | 143 | unexpected_message = 144 | ~s("else" clauses will never match because all patterns in "with" will always match) 145 | 146 | message = 147 | capture_io(:stderr, fn -> 148 | string = """ 149 | defmodule AsyncWithTest.C do 150 | use AsyncWith 151 | 152 | def test do 153 | async with do 154 | 2 155 | else 156 | :error -> :error 157 | end 158 | end 159 | end 160 | """ 161 | 162 | Code.eval_string(string) 163 | end) 164 | 165 | assert warnings_count(message) == 1 166 | assert message =~ expexted_message 167 | refute message =~ unexpected_message 168 | end 169 | 170 | test "emits a warning if 'else' clauses will never match (without clauses, single line)" do 171 | expexted_message = 172 | ~s("else" clauses will never match because all patterns in "async with" will always match) 173 | 174 | unexpected_message = 175 | ~s("else" clauses will never match because all patterns in "with" will always match) 176 | 177 | message = 178 | capture_io(:stderr, fn -> 179 | string = """ 180 | defmodule AsyncWithTest.D do 181 | use AsyncWith 182 | 183 | def test do 184 | async with, do: 2, else: (:error -> :error) 185 | end 186 | end 187 | """ 188 | 189 | Code.eval_string(string) 190 | end) 191 | 192 | assert warnings_count(message) == 1 193 | assert message =~ expexted_message 194 | refute message =~ unexpected_message 195 | end 196 | 197 | test "does not emit a warning if 'else' clauses are missing and clauses will always match" do 198 | message = 199 | capture_io(:stderr, fn -> 200 | string = """ 201 | defmodule AsyncWithTest.E do 202 | use AsyncWith 203 | 204 | def test do 205 | async with a <- 1, b = 2 do 206 | a + b 207 | end 208 | end 209 | 210 | def test_single_line do 211 | async with a <- 1, b = 2, do: a + b 212 | end 213 | end 214 | """ 215 | 216 | Code.eval_string(string) 217 | end) 218 | 219 | assert message == "" 220 | end 221 | 222 | test "does not emit a warning `warning: variable '' is unused` with rebinded vars" do 223 | message = 224 | capture_io(:stderr, fn -> 225 | string = """ 226 | defmodule AsyncWithTest.F do 227 | use AsyncWith 228 | 229 | def test do 230 | async with a <- 1, 231 | b <- 2, 232 | a <- a + 3 do 233 | a + b 234 | end 235 | end 236 | end 237 | """ 238 | 239 | Code.eval_string(string) 240 | end) 241 | 242 | assert message == "" 243 | end 244 | 245 | test "does not emit a warning `warning: variable '' is unused` with temp vars" do 246 | message = 247 | capture_io(:stderr, fn -> 248 | string = """ 249 | defmodule AsyncWithTest.G do 250 | use AsyncWith 251 | 252 | def test do 253 | async with a <- 1, 254 | b <- 2, 255 | c <- a + b do 256 | c 257 | end 258 | end 259 | end 260 | """ 261 | 262 | Code.eval_string(string) 263 | end) 264 | 265 | assert message == "" 266 | end 267 | 268 | test "emits a warning if the result of the expression is not being used" do 269 | expexted_message = 270 | "the result of the expression is ignored (suppress the warning by assigning the " <> 271 | "expression to the _ variable)" 272 | 273 | message = 274 | capture_io(:stderr, fn -> 275 | string = """ 276 | defmodule AsyncWithTest.H do 277 | use AsyncWith 278 | 279 | def test do 280 | async with a <- 1, 281 | b <- 2 do 282 | a + b 283 | end 284 | 285 | :test 286 | end 287 | end 288 | """ 289 | 290 | Code.eval_string(string) 291 | end) 292 | 293 | assert warnings_count(message) == 1 294 | assert message =~ expexted_message 295 | end 296 | 297 | test "can be used outside of a module" do 298 | {value, _binding} = 299 | Code.eval_string(""" 300 | use AsyncWith 301 | 302 | async with a <- 1, 303 | b <- 2 do 304 | a + b 305 | end 306 | """) 307 | 308 | assert value == 3 309 | end 310 | 311 | test "works without clauses" do 312 | result = 313 | async with do 314 | 1 315 | end 316 | 317 | assert result == 1 318 | end 319 | 320 | test "works without clauses (single line)" do 321 | result = async with, do: 1 322 | 323 | assert result == 1 324 | end 325 | 326 | test "works with one clause" do 327 | result = 328 | async with {:ok, a} <- echo("a") do 329 | a 330 | end 331 | 332 | assert result == "a" 333 | end 334 | 335 | test "works with one clause (single line)" do 336 | result = async with {:ok, a} <- echo("a"), do: a 337 | 338 | assert result == "a" 339 | end 340 | 341 | test "works with several clauses" do 342 | result = 343 | async with {:ok, a} <- echo("a"), 344 | {:ok, b} <- echo("b"), 345 | {:ok, c} <- echo("c"), 346 | {:ok, d} <- echo("d"), 347 | {:ok, e} <- echo("e"), 348 | {:ok, f} <- echo("f"), 349 | {:ok, g} <- echo("g") do 350 | Enum.join([a, b, c, d, e, f, g], " ") 351 | end 352 | 353 | assert result == "a b c d e f g" 354 | end 355 | 356 | test "works with several clauses (single line)" do 357 | result = 358 | async with {:ok, a} <- echo("a"), 359 | {:ok, b} <- echo("b"), 360 | {:ok, c} <- echo("c"), 361 | {:ok, d} <- echo("d"), 362 | {:ok, e} <- echo("e"), 363 | {:ok, f} <- echo("f"), 364 | {:ok, g} <- echo("g"), 365 | do: Enum.join([a, b, c, d, e, f, g], " ") 366 | 367 | assert result == "a b c d e f g" 368 | end 369 | 370 | test "works with clauses that depend on variables binded in previous clauses" do 371 | result = 372 | async with {:ok, a} <- echo("a"), 373 | b = "b", 374 | {:ok, c} <- echo("c(#{a})"), 375 | {:ok, d} <- echo("d"), 376 | {:ok, e} <- echo("e(#{a})"), 377 | {:ok, f} <- echo("f(#{e}, #{d})"), 378 | {:ok, g} <- echo("g(#{e})"), 379 | {:ok, h} <- echo("h(#{f})"), 380 | i = "i", 381 | {:ok, j} <- echo("j(#{h}, #{i})") do 382 | Enum.join([a, b, c, d, e, f, g, h, i, j], " ") 383 | end 384 | 385 | assert result == "a b c(a) d e(a) f(e(a), d) g(e(a)) h(f(e(a), d)) i j(h(f(e(a), d)), i)" 386 | end 387 | 388 | test "works with clauses that reference external variables" do 389 | a = "a" 390 | b = "b" 391 | 392 | result = 393 | async with {:ok, c} <- echo("c(#{a})"), 394 | {:ok, d} <- echo("d"), 395 | {:ok, e} <- echo("e(#{a})"), 396 | {:ok, f} <- echo("f(#{e}, #{d})"), 397 | {:ok, g} <- echo("g(#{e})"), 398 | {:ok, h} <- echo("h(#{f})"), 399 | {:ok, i} <- echo("i"), 400 | {:ok, j} <- echo("j(#{h}, #{i})") do 401 | Enum.join([a, b, c, d, e, f, g, h, i, j], " ") 402 | end 403 | 404 | assert result == "a b c(a) d e(a) f(e(a), d) g(e(a)) h(f(e(a), d)) i j(h(f(e(a), d)), i)" 405 | end 406 | 407 | test "works with clauses with pin matching" do 408 | ok = :ok 409 | e = "e(a)" 410 | 411 | result = 412 | async with {:ok, a} <- echo("a"), 413 | b = "b", 414 | {:ok, c} <- echo("c(#{a})"), 415 | {^ok, d} <- echo("d"), 416 | {:ok, ^e} <- echo("e(#{a})"), 417 | {:ok, f} <- echo("f(#{e}, #{d})"), 418 | {ok, g} <- echo("g(#{e})"), 419 | {:ok, h} <- echo("h(#{f})"), 420 | i = "i", 421 | {^ok, j} <- echo("j(#{h}, #{i})") do 422 | Enum.join([a, b, c, d, e, f, g, h, i, j], " ") 423 | end 424 | 425 | assert result == "a b c(a) d e(a) f(e(a), d) g(e(a)) h(f(e(a), d)) i j(h(f(e(a), d)), i)" 426 | end 427 | 428 | test "works with clauses with ignored and unbound variables" do 429 | result = 430 | async with _..42 <- 1..42, 431 | {_ok, a} <- echo("a"), 432 | {_, _} = b <- echo("b(#{a})"), 433 | {_, _} <- {"c", "c"} do 434 | {:ok, b} = b 435 | Enum.join([a, b], " ") 436 | end 437 | 438 | assert result == "a b(a)" 439 | end 440 | 441 | test "works with clauses with variable rebinding" do 442 | a = "a" 443 | b = "b" 444 | 445 | result = 446 | async with {:ok, c} <- echo("c(#{a})"), 447 | {:ok, d} <- echo("d"), 448 | {:ok, _} = a <- echo("A"), 449 | {:ok, a} <- a, 450 | {:ok, e} <- echo("e(#{a})"), 451 | {:ok, f} <- echo("f(#{e}, #{d})"), 452 | {:ok, g} <- echo("g(#{e})"), 453 | {:ok, _} = a <- echo("ä"), 454 | {:ok, a} <- a, 455 | e = "E", 456 | {:ok, h} <- echo("h(#{f}, #{e})"), 457 | {:ok, i} <- echo("i"), 458 | {:ok, j} <- echo("j(#{h}, #{i})") do 459 | Enum.join([a, b, c, d, e, f, g, h, i, j], " ") 460 | end 461 | 462 | assert result == "ä b c(a) d E f(e(A), d) g(e(A)) h(f(e(A), d), E) i j(h(f(e(A), d), E), i)" 463 | end 464 | 465 | test "works with clauses with guards" do 466 | ok = :ok 467 | e = "e(a)" 468 | 469 | result = 470 | async with {:ok, a} <- echo("a"), 471 | b = "b", 472 | {:ok, c} <- echo("c(#{a})"), 473 | {^ok, d} <- echo("d"), 474 | {:ok, ^e} <- echo("e(#{a})"), 475 | {:ok, f} when is_binary(f) <- echo("f(#{e}, #{d})"), 476 | {ok, g} when ok == :ok <- echo("g(#{e})"), 477 | {:ok, h} when is_binary(h) <- echo("h(#{f})"), 478 | i = "i", 479 | {^ok, j} <- echo("j(#{h}, #{i})") do 480 | Enum.join([a, b, c, d, e, f, g, h, i, j], " ") 481 | end 482 | 483 | assert result == "a b c(a) d e(a) f(e(a), d) g(e(a)) h(f(e(a), d)) i j(h(f(e(a), d)), i)" 484 | end 485 | 486 | test "works with clauses with complex pattern matching" do 487 | ok = :ok 488 | 489 | result = 490 | async with true <- get_true(), 491 | {{:ok, a}, {:ok, a}} <- {echo("a"), echo("a")}, 492 | {^ok, {^ok, b}, {:ok, b}} <- {:ok, echo("b(#{a})"), {:ok, "b(a)"}} do 493 | Enum.join([a, b], " ") 494 | end 495 | 496 | assert result == "a b(a)" 497 | end 498 | 499 | test "works with bare expressions" do 500 | {:ok, agent} = Agent.start_link(fn -> 0 end) 501 | 502 | result = 503 | async with {:ok, a} <- echo(1), 504 | Agent.update(agent, fn count -> count + a end), 505 | {:ok, b} <- echo(2), 506 | Agent.update(agent, fn count -> count + b end) do 507 | Enum.join([a, b], " ") 508 | end 509 | 510 | assert result == "1 2" 511 | assert Agent.get(agent, & &1) == 3 512 | 513 | :ok = Agent.stop(agent) 514 | end 515 | 516 | test "raises MatchError when the sides of a clause does not match" do 517 | assert_raise MatchError, "no match of right hand side value: :error", fn -> 518 | async with {:ok, a} <- echo("a"), {:ok, b} = error(a) do 519 | Enum.join([a, b], " ") 520 | end 521 | end 522 | end 523 | 524 | test "returns the error if no else conditions are present" do 525 | result = 526 | async with {:ok, a} <- echo("a"), 527 | {:ok, b} <- echo("b"), 528 | {:ok, c} <- echo("c"), 529 | {:ok, d} <- echo("d(#{a})"), 530 | {:ok, e} <- error("e(#{b})"), 531 | {:ok, f} <- echo("f(#{b})"), 532 | {:ok, g} <- echo("g(#{e})") do 533 | Enum.join([a, b, c, d, e, f, g], " ") 534 | end 535 | 536 | assert result == :error 537 | end 538 | 539 | test "executes else conditions when present" do 540 | result = 541 | async with {:ok, a} <- echo("a"), 542 | {:ok, b} <- echo("b"), 543 | {:ok, c} <- echo("c"), 544 | {:ok, d} <- echo("d(#{a})"), 545 | {:ok, e} <- error("e(#{b})"), 546 | {:ok, f} <- echo("f(#{b})"), 547 | {:ok, g} <- echo("g(#{e})") do 548 | Enum.join([a, b, c, d, e, f, g], " ") 549 | else 550 | {:error, error} -> error 551 | :error -> :test 552 | end 553 | 554 | assert result == :test 555 | end 556 | 557 | test "executes else conditions when present (single line)" do 558 | result = 559 | async with {:ok, a} <- echo("a"), 560 | {:ok, b} <- echo("b"), 561 | {:ok, c} <- echo("c"), 562 | {:ok, d} <- echo("d(#{a})"), 563 | {:ok, e} <- error("e(#{b})"), 564 | {:ok, f} <- echo("f(#{b})"), 565 | {:ok, g} <- echo("g(#{e})"), 566 | do: Enum.join([a, b, c, d, e, f, g], " "), 567 | else: 568 | ( 569 | {:error, error} -> error 570 | :error -> :test 571 | ) 572 | 573 | assert result == :test 574 | end 575 | 576 | test "allows guards on else conditions" do 577 | result = 578 | async with {:ok, a} <- echo("a"), 579 | {:ok, b} <- echo("b"), 580 | {:ok, c} <- echo("c"), 581 | {:ok, d} <- echo("d(#{a})"), 582 | {:ok, e} <- error("e(#{b})"), 583 | {:ok, f} <- echo("f(#{b})"), 584 | {:ok, g} <- echo("g(#{e})") do 585 | Enum.join([a, b, c, d, e, f, g], " ") 586 | else 587 | error when is_atom(error) -> error 588 | end 589 | 590 | assert result == :error 591 | end 592 | 593 | test "does not leak variables to else conditions" do 594 | value = 1 595 | 596 | result = 597 | async with 1 <- value, 598 | value = 2, 599 | :ok <- error() do 600 | value 601 | else 602 | _ -> value 603 | end 604 | 605 | assert result == 1 606 | assert value == 1 607 | end 608 | 609 | test "raises AsyncWith.ClauseError when there are not else condition that match the error" do 610 | assert_raise AsyncWith.ClauseError, "no async with clause matching: :error", fn -> 611 | async with {:ok, value} <- error() do 612 | value 613 | else 614 | {:error, error} -> error 615 | end 616 | end 617 | end 618 | 619 | test "does not override CaseClauseError produced inside of else conditions" do 620 | assert_raise CaseClauseError, "no case clause matching: :error", fn -> 621 | async with {:ok, value} <- error() do 622 | value 623 | else 624 | :error = error -> 625 | case error do 626 | {:error, error} -> error 627 | end 628 | end 629 | end 630 | end 631 | 632 | test "does not override WithClauseError produced inside of else conditions" do 633 | assert_raise WithClauseError, "no with clause matching: :error", fn -> 634 | async with {:ok, value} <- error() do 635 | value 636 | else 637 | :error = error -> 638 | with {:ok, value} <- error do 639 | value 640 | else 641 | {:error, error} -> error 642 | end 643 | end 644 | end 645 | end 646 | 647 | test "re-throws uncaught values" do 648 | result = 649 | try do 650 | async with _ <- throw(:test), do: :error 651 | catch 652 | :test -> :ok 653 | end 654 | 655 | assert result == :ok 656 | end 657 | 658 | test "re-raises unrescued errors" do 659 | result = 660 | try do 661 | async with _ <- raise("oops"), do: :error 662 | rescue 663 | error -> error 664 | end 665 | 666 | assert result == %RuntimeError{message: "oops"} 667 | end 668 | 669 | test "returns `{:exit, :normal}` on normal exit" do 670 | result = async with _ <- exit(:normal), do: :ok 671 | 672 | assert result == {:exit, :normal} 673 | end 674 | 675 | test "returns `{:exit, {:timeout, {AsyncWith, :async, [@async_with_timeout]}}}` on timeout" do 676 | result = 677 | async with {:ok, a} <- echo("a"), 678 | {:ok, b} <- echo("b"), 679 | {:ok, c} <- echo("c"), 680 | {:ok, d} <- echo("d(#{a})"), 681 | {:ok, e} <- delayed_echo("e(#{b})", @async_with_timeout + 10), 682 | {:ok, f} <- echo("f(#{b})"), 683 | {:ok, g} <- echo("g(#{e})") do 684 | Enum.join([a, b, c, d, e, f, g], " ") 685 | end 686 | 687 | assert result == {:exit, {:timeout, {AsyncWith, :async, [50]}}} 688 | end 689 | 690 | test "executes else conditions on timeout" do 691 | result = 692 | async with {:ok, a} <- echo("a"), 693 | {:ok, b} <- echo("b"), 694 | {:ok, c} <- echo("c"), 695 | {:ok, d} <- echo("d(#{a})"), 696 | {:ok, e} <- delayed_echo("e(#{b})", @async_with_timeout + 10), 697 | {:ok, f} <- echo("f(#{b})"), 698 | {:ok, g} <- echo("g(#{e})") do 699 | Enum.join([a, b, c, d, e, f, g], " ") 700 | else 701 | {:exit, {:timeout, _}} -> :timeout 702 | end 703 | 704 | assert result == :timeout 705 | end 706 | 707 | test "executes each clause in a different process" do 708 | result = 709 | async with {:ok, pid_a} <- pid(), 710 | {:ok, pid_b} <- pid(), 711 | {:ok, pid_c} <- pid(pid_a), 712 | {:ok, pid_d} <- pid(), 713 | {:ok, pid_e} <- pid(pid_a), 714 | {:ok, pid_f} <- pid([pid_e, pid_d]), 715 | {:ok, pid_g} <- pid(pid_e), 716 | {:ok, pid_h} <- pid(pid_f), 717 | {:ok, pid_i} <- pid(), 718 | {:ok, pid_j} <- pid([pid_h, pid_i]) do 719 | Enum.uniq([pid_a, pid_b, pid_c, pid_d, pid_e, pid_f, pid_g, pid_h, pid_i, pid_j]) 720 | else 721 | _ -> [] 722 | end 723 | 724 | assert length(result) == 10 725 | end 726 | 727 | test "kills all the spawned processes on error" do 728 | {:ok, agent} = Agent.start_link(fn -> %{} end) 729 | 730 | result = 731 | async with {:ok, a} <- echo("a"), 732 | {:ok, b} <- delayed_echo("b(#{a})", 10), 733 | {:ok, c} <- echo("c(#{a})"), 734 | {:ok, d} <- error("d(#{b})"), 735 | {:ok, e} <- {register_pid_and_wait(agent, :e), "e(#{c})"}, 736 | {:ok, f} <- {register_pid_and_wait(agent, :f), "f(#{c})"}, 737 | {:ok, g} <- {register_pid_and_wait(agent, :g), "g(#{e})"} do 738 | Enum.join([a, b, c, d, e, f, g], " ") 739 | end 740 | 741 | pids = Agent.get(agent, & &1) 742 | 743 | assert result == :error 744 | refute Process.alive?(pids.e) 745 | refute Process.alive?(pids.f) 746 | refute Map.has_key?(pids, :g) 747 | 748 | :ok = Agent.stop(agent) 749 | end 750 | 751 | @tag :capture_log 752 | test "kills all the spawned processes when an error is raised" do 753 | {:ok, agent} = Agent.start_link(fn -> %{} end) 754 | 755 | result = 756 | try do 757 | async with {:ok, a} <- echo("a"), 758 | {:ok, b} <- echo("b(#{a})"), 759 | {:ok, c} <- echo("c(#{a})"), 760 | {:ok, d} <- {register_pid_and_wait(agent, :d), "d(#{b})"}, 761 | {:ok, e} <- {register_pid_and_wait(agent, :e), "e(#{c})"}, 762 | {:ok, f} <- delay(10, fn -> raise_oops("f(#{c})") end), 763 | {:ok, g} <- {register_pid_and_wait(agent, :g), "g(#{e})"} do 764 | Enum.join([a, b, c, d, e, f, g], " ") 765 | end 766 | rescue 767 | error -> error 768 | end 769 | 770 | pids = Agent.get(agent, & &1) 771 | 772 | assert result == %RuntimeError{message: "oops"} 773 | refute Process.alive?(pids.d) 774 | refute Process.alive?(pids.e) 775 | refute Map.has_key?(pids, :g) 776 | 777 | :ok = Agent.stop(agent) 778 | end 779 | 780 | @tag :capture_log 781 | test "kills all the spawned processes when a value is thrown" do 782 | {:ok, agent} = Agent.start_link(fn -> %{} end) 783 | 784 | result = 785 | try do 786 | async with {:ok, a} <- echo("a"), 787 | {:ok, b} <- echo("b(#{a})"), 788 | {:ok, c} <- echo("c(#{a})"), 789 | {:ok, d} <- {register_pid_and_wait(agent, :d), "d(#{b})"}, 790 | {:ok, e} <- {register_pid_and_wait(agent, :e), "e(#{c})"}, 791 | {:ok, f} <- delay(10, fn -> throw("f(#{c})") end), 792 | {:ok, g} <- {register_pid_and_wait(agent, :g), "g(#{e})"} do 793 | Enum.join([a, b, c, d, e, f, g], " ") 794 | end 795 | catch 796 | thrown_value -> thrown_value 797 | end 798 | 799 | pids = Agent.get(agent, & &1) 800 | 801 | assert result == "f(c(a))" 802 | refute Process.alive?(pids.d) 803 | refute Process.alive?(pids.e) 804 | refute Map.has_key?(pids, :g) 805 | 806 | :ok = Agent.stop(agent) 807 | end 808 | 809 | test "kills all the spawned processes on timeout" do 810 | {:ok, agent} = Agent.start_link(fn -> %{} end) 811 | 812 | result = 813 | async with {:ok, a} <- echo("a"), 814 | {:ok, b} <- echo("b(#{a})"), 815 | {:ok, c} <- echo("c(#{a})"), 816 | {:ok, d} <- {register_pid_and_wait(agent, :d), "d(#{b})"}, 817 | {:ok, e} <- {register_pid_and_wait(agent, :e), "e(#{c})"}, 818 | {:ok, f} <- delayed_echo("f(#{c})", @async_with_timeout + 10), 819 | {:ok, g} <- {register_pid_and_wait(agent, :g), "g(#{e})"} do 820 | Enum.join([a, b, c, d, e, f, g], " ") 821 | end 822 | 823 | pids = Agent.get(agent, & &1) 824 | 825 | assert result == {:exit, {:timeout, {AsyncWith, :async, [50]}}} 826 | refute Process.alive?(pids.d) 827 | refute Process.alive?(pids.e) 828 | refute Map.has_key?(pids, :g) 829 | 830 | :ok = Agent.stop(agent) 831 | end 832 | 833 | @tag :capture_log 834 | test "kills all the spawned processes on exit" do 835 | {:ok, agent} = Agent.start_link(fn -> %{} end) 836 | 837 | result = 838 | async with {:ok, a} <- echo("a"), 839 | {:ok, b} <- echo("b_#{a}_"), 840 | {:ok, c} <- echo("c_#{a}_"), 841 | {:ok, d} <- {register_pid_and_wait(agent, :d), "d_#{b}_"}, 842 | {:ok, e} <- {register_pid_and_wait(agent, :e), "e_#{c}_"}, 843 | {:ok, f} <- delay(10, fn -> exit(:"f_#{c}_") end), 844 | {:ok, g} <- {register_pid_and_wait(agent, :g), "g_#{e}_"} do 845 | Enum.join([a, b, c, d, e, f, g], " ") 846 | end 847 | 848 | pids = Agent.get(agent, & &1) 849 | 850 | assert result == {:exit, :f_c_a__} 851 | refute Process.alive?(pids.d) 852 | refute Process.alive?(pids.e) 853 | refute Map.has_key?(pids, :g) 854 | 855 | :ok = Agent.stop(agent) 856 | end 857 | 858 | @async_with_timeout 100 859 | test "optimizes the execution" do 860 | started_at = System.system_time(:millisecond) 861 | 862 | result = 863 | async with {:ok, a} <- delayed_echo("a", 20), 864 | {:ok, b} <- delayed_echo("b", 20), 865 | {:ok, c} <- delayed_echo("c", 40), 866 | {:ok, d} <- delayed_echo("d(#{a})", 20), 867 | {:ok, e} <- delayed_echo("e(#{b})", 40), 868 | {:ok, f} <- delayed_echo("f(#{b})", 20), 869 | {:ok, g} <- delayed_echo("g(#{e})", 20) do 870 | Enum.join([a, b, c, d, e, f, g], " ") 871 | end 872 | 873 | # The dependency graph is: 874 | # 875 | # A(20) B(20) C(40) 876 | # ↓ ↙ ↘ 877 | # C(20) E(40) F(20) 878 | # ↓ 879 | # G(20) 880 | # 881 | # The most time consuming path should be B -> E -> G ~ 80 milliseconds 882 | 883 | finished_at = System.system_time(:millisecond) 884 | 885 | assert result == "a b c d(a) e(b) f(b) g(e(b))" 886 | assert finished_at - started_at < 95 887 | end 888 | 889 | test "clauses should be executed as soon as their dependencies are resolved" do 890 | {:ok, agent} = Agent.start_link(fn -> 0 end) 891 | 892 | task = 893 | Task.async(fn -> 894 | :timer.sleep(1) 895 | Agent.get(agent, & &1) 896 | end) 897 | 898 | result = 899 | async with {:ok, a} <- echo("a"), 900 | {:ok, b} <- delayed_echo("b", 20), 901 | {:ok, c} <- {Agent.update(agent, fn _ -> 1 end), "c(#{a})"}, 902 | {:ok, d} <- echo("d(#{a}, #{b})") do 903 | Enum.join([a, b, c, d], " ") 904 | end 905 | 906 | # c should not wait for b 907 | assert Task.await(task) == 1 908 | assert result == "a b c(a) d(a, b)" 909 | 910 | :ok = Agent.stop(agent) 911 | end 912 | 913 | test "errors with the same internal representation are not misinterpreted" do 914 | result = 915 | async with {:ok, a} <- echo("a"), 916 | {:ok, b, c} <- {:ok, [a: 1]} do 917 | Enum.join([a, b, c], " ") 918 | end 919 | 920 | assert result == {:ok, [a: 1]} 921 | end 922 | 923 | test "internal variable 'values' cannot be accessed outside the macro's context" do 924 | values = ["X", "Y", "Z"] 925 | 926 | result = async with _ <- 1, do: values 927 | 928 | assert result == ["X", "Y", "Z"] 929 | assert values == ["X", "Y", "Z"] 930 | end 931 | 932 | test "internal variable 'values' cannot be rebinded" do 933 | result = 934 | async with a <- "a", 935 | b <- "b", 936 | c <- "c", 937 | values <- [a, b, c], 938 | d <- hd(values) do 939 | Enum.join([a, b, c, d], " ") 940 | end 941 | 942 | assert result == "a b c a" 943 | end 944 | 945 | defp warnings_count(string) do 946 | length(String.split(string, "warning:")) - 1 947 | end 948 | 949 | defp delay(delay, fun) when is_function(fun) do 950 | :timer.sleep(delay) 951 | fun.() 952 | end 953 | 954 | defp echo(value) do 955 | {:ok, value} 956 | end 957 | 958 | defp delayed_echo(value, delay) do 959 | :timer.sleep(delay) 960 | echo(value) 961 | end 962 | 963 | defp get_true(_value \\ nil) do 964 | true 965 | end 966 | 967 | defp error(_value \\ nil) do 968 | :error 969 | end 970 | 971 | defp raise_oops(_value) do 972 | raise("oops") 973 | end 974 | 975 | defp pid(_value \\ nil) do 976 | {:ok, self()} 977 | end 978 | 979 | defp register_pid(agent, key) do 980 | pid = self() 981 | Agent.update(agent, &Map.merge(&1, %{key => pid})) 982 | :ok 983 | end 984 | 985 | defp register_pid_and_wait(agent, key) do 986 | register_pid(agent, key) 987 | # Wait to be killed 988 | :timer.sleep(1_000) 989 | end 990 | end 991 | --------------------------------------------------------------------------------