├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── config └── config.exs ├── lib └── ecto_function.ex ├── mix.exs └── test ├── ecto_function_test.exs ├── support └── migration.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: [ 3 | ".formatter.exs", 4 | "mix.exs", 5 | "lib/**.ex", 6 | "test/**.exs" 7 | ], 8 | import_deps: [:ecto], 9 | locals_without_parens: [defqueryfunc: 1, defqueryfunc: 2], 10 | export: [ 11 | locals_without_parens: [defqueryfunc: 1, defqueryfunc: 2] 12 | ] 13 | ] 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | 7 | build: 8 | name: Build OTP ${{ matrix.otp }} / Elixir ${{ matrix.elixir }} 9 | runs-on: ubuntu-18.04 10 | 11 | services: 12 | pg: 13 | image: postgres:14 14 | env: 15 | POSTGRES_USER: postgres 16 | POSTGRES_PASSWORD: postgres 17 | POSTGRES_DB: ecto_function_test 18 | options: >- 19 | --health-cmd pg_isready 20 | --health-interval 10s 21 | --health-timeout 5s 22 | --health-retries 10 23 | ports: 24 | - 5432:5432 25 | volumes: 26 | - /var/run/postgresql:/var/run/postgresql 27 | 28 | strategy: 29 | matrix: 30 | include: 31 | - elixir: 1.10.4 32 | otp: 21.3 33 | - elixir: 1.11.4 34 | otp: 23.2 35 | - elixir: 1.12.0 36 | otp: 24.0 37 | steps: 38 | - uses: actions/checkout@v2 39 | - name: Setup Elixir and Erlang 40 | uses: erlef/setup-beam@v1 41 | with: 42 | otp-version: ${{ matrix.otp }} 43 | elixir-version: ${{ matrix.elixir }} 44 | 45 | - uses: actions/cache@v2 46 | with: 47 | path: | 48 | deps 49 | _build 50 | key: mix-${{matrix.elixir}}-${{matrix.otp}}-${{ hashFiles('**/mix.lock') }} 51 | restore-keys: | 52 | mix- 53 | 54 | - run: mix deps.get 55 | 56 | - run: mix format --check-formatted 57 | 58 | - run: mix deps.compile 59 | 60 | - run: mix compile --warnings-as-errors 61 | 62 | - run: mix test 63 | 64 | - name: Run credo with reviewdog 65 | uses: red-shirts/reviewdog-action-credo@v1 66 | with: 67 | github_token: ${{ secrets.github_token }} 68 | -------------------------------------------------------------------------------- /.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 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | mix.lock 22 | /log 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Łukasz Jan Niemier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ecto.Function 2 | 3 | [![Hex.pm](https://img.shields.io/hexpm/dt/ecto_function.svg)](https://hex.pm/packages/ecto_function) 4 | [![Travis](https://img.shields.io/travis/hauleth/ecto_function.svg)](https://travis-ci.org/hauleth/ecto_function) 5 | 6 | Helper macro for defining macros that simplifies calling DB functions. 7 | 8 | ## Installation 9 | 10 | The package can be installed by adding `ecto_function` to your list of dependencies in `mix.exs`: 11 | 12 | ```elixir 13 | def deps do 14 | [ 15 | {:ecto_function, "~> 1.0.1"} 16 | ] 17 | end 18 | ``` 19 | 20 | The docs can be found at . 21 | 22 | ## Usage 23 | 24 | When you use a lot of DB functions inside your queries then this probably looks 25 | like this: 26 | 27 | ```elixir 28 | from item in "items", 29 | where: fragment("date_trunc(?, ?)", "hour", item.inserted_at) < fragment("date_trunc(?, ?)", "hour", fragment("now()")), 30 | select: %{regr: fragment("regr_sxy(?, ?)", item.y, item.x)} 31 | ``` 32 | 33 | There are a lot of `fragment` calls which makes code quite challenging to read. 34 | However there is way out for such code, you can write macros: 35 | 36 | ```elixir 37 | defmodule Foo do 38 | defmacro date_trunc(part, field) do 39 | quote do: fragment("date_trunc(?, ?)", ^part, ^field) 40 | end 41 | 42 | defmacro now do 43 | quote do: fragment("now()") 44 | end 45 | 46 | defmacro regr_sxy(y, x) do 47 | quote do: fragment("regr_sxy(y, x)", ^y, ^x) 48 | end 49 | end 50 | ``` 51 | 52 | And then cleanup your query to: 53 | 54 | ```elixir 55 | import Foo 56 | import Ecto.Query 57 | 58 | from item in "items", 59 | where: date_trunc("hour", item.inserted_at) < date_trunc("hour", now()), 60 | select: %{regr: regr_sxy(item.y, item.x)} 61 | ``` 62 | 63 | However there is still a lot of repetition in your new fancy helper module. You 64 | need to repeat function name twice, name each argument, insert all that carets 65 | and stuff. 66 | 67 | What about little help? 68 | 69 | ```elixir 70 | defmodule Foo do 71 | import Ecto.Function 72 | 73 | defqueryfunc date_trunc(part, field) 74 | defqueryfunc now 75 | defqueryfunc regr_sxy/2 76 | end 77 | ``` 78 | 79 | Much cleaner… 80 | 81 | ## Reasoning 82 | 83 | [Your DB is powerful](http://modern-sql.com/slides). Really. A lot of 84 | computations can be done there. There is whole [chapter][chapter] dedicated to 85 | describing all PostgreSQL functions and Ecto supports only few of them: 86 | 87 | - `sum` 88 | - `avg` 89 | - `min` 90 | - `max` 91 | 92 | To be exact. Saying that we have "limited choice" would be disrespectful to DB 93 | systems like PostgreSQL or Oracle. Of course Ecto core team have walid reasoning 94 | to support only that much functions: these are available in probably any DB 95 | system ever, so supporting them directly in library is no brainer. However you 96 | as end-user shouldn't be limited to so small set. Let's be honest, you probably 97 | will never change your DB engine, and if you do so, then you probably rewrite 98 | while system from the ground. So this is why this module was created. To provide 99 | you access to all functions in your SQL DB (could work with NoSQL DB also, but I 100 | test only against PostgreSQL). 101 | 102 | For completeness you can also check [Ecto.OLAP][olap] which provide helpers for 103 | some more complex functionalities like `GROUPING` (and in near future also 104 | window functions). 105 | 106 | ### But why not introduce that directly to Ecto? 107 | 108 | Because there is no need. Personally I would like to see Ecto splitted a little, 109 | like changesets should be in separate library in my humble opinion. Also I 110 | believe that such PR would never be merged as "non scope" for the reasons I gave 111 | earlier. 112 | 113 | [chapter]: https://www.postgresql.org/docs/current/static/functions.html "Chapter 9. Functions and Operators" 114 | [olap]: https://github.com/hauleth/ecto_olap 115 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | config :logger, level: :warn 6 | 7 | config :ecto_function, Ecto.Integration.Repo, 8 | username: "postgres", 9 | password: "postgres", 10 | database: "ecto_function_test", 11 | pool: Ecto.Adapters.SQL.Sandbox 12 | -------------------------------------------------------------------------------- /lib/ecto_function.ex: -------------------------------------------------------------------------------- 1 | defmodule Ecto.Function do 2 | @moduledoc """ 3 | Helper macro for defining helper macros for calling DB functions. 4 | 5 | A little Xzibity, but helps. 6 | """ 7 | 8 | @doc ~S""" 9 | Define new SQL function call. 10 | 11 | ## Options 12 | 13 | Currently there is only one option allowed: 14 | 15 | - `for` - define DB function name to call 16 | 17 | ## Examples 18 | 19 | import Ecto.Function 20 | 21 | defqueryfunc foo # Define function without params 22 | defqueryfunc bar(a, b) # Explicit parameter names 23 | defqueryfunc baz/1 # Define function using arity 24 | defqueryfunc qux(a, b \\ 0) # Define function with default arguments 25 | defqueryfunc quux/1, for: "db_quux" # Define with alternative DB call 26 | 27 | Then calling such functions in query would be equivalent to: 28 | 29 | from _ in "foos", select: %{foo: foo()} 30 | # => SELECT foo() AS foo FROM foos 31 | 32 | from q in "bars", select: %{bar: bar(q.a, q.b)} 33 | # => SELECT bar(bars.a, bars.b) AS bar FROM bars 34 | 35 | from q in "bazs", where: baz(q.a) == true 36 | # => SELECT * FROM bazs WHERE baz(bazs.a) = TRUE 37 | 38 | from q in "quxs", select: %{one: qux(q.a), two: qux(q.a, q.b)} 39 | # => SELECT 40 | # qux(quxs.a, 0) AS one, 41 | # qux(quxs.a, quxs.b) AS two 42 | # FROM "quxs" 43 | 44 | from q in "quuxs", select: %{quux: quux(q.a)} 45 | # => SELECT db_quux(quuxs.a) FROM quuxs 46 | 47 | ## Gotchas 48 | 49 | If your function uses "special syntax" like PostgreSQL [`extract`][extract] 50 | then this module won't help you and you will be required to write your own 51 | macro that will handle such case. 52 | 53 | defmacro extract(from, field) do 54 | quote do: fragment("extract(? FROM ?)", field, from) 55 | end 56 | 57 | This case probably will never be supported in this library and you should 58 | handle it on your own. 59 | 60 | [extract]: https://www.postgresql.org/docs/current/static/functions-datetime.html#functions-datetime-extract 61 | """ 62 | defmacro defqueryfunc(definition, opts \\ []) 63 | 64 | defmacro defqueryfunc({:/, _, [{name, _, _}, params_count]}, opts) 65 | when is_atom(name) and is_integer(params_count) do 66 | require Logger 67 | 68 | opts = Keyword.put_new(opts, :for, name) 69 | params = Macro.generate_arguments(params_count, Elixir) 70 | 71 | Logger.warn(""" 72 | func/arity syntax is deprecated, instead use: 73 | 74 | defqueryfunc #{Macro.to_string(quote do: unquote(name)(unquote_splicing(params)))} 75 | """) 76 | 77 | macro(name, params, __CALLER__, opts) 78 | end 79 | 80 | defmacro defqueryfunc({name, _, params}, opts) 81 | when is_atom(name) and is_list(params) do 82 | opts = Keyword.put_new(opts, :for, name) 83 | 84 | macro(name, params, __CALLER__, opts) 85 | end 86 | 87 | defmacro defqueryfunc({name, _, _}, opts) when is_atom(name) do 88 | opts = Keyword.put_new(opts, :for, name) 89 | 90 | macro(name, [], __CALLER__, opts) 91 | end 92 | 93 | defmacro defqueryfunc(tree, _) do 94 | raise CompileError, 95 | file: __CALLER__.file, 96 | line: __CALLER__.line, 97 | description: "Unexpected query function definition #{Macro.to_string(tree)}." 98 | end 99 | 100 | defp macro(name, params, caller, opts) do 101 | sql_name = Keyword.fetch!(opts, :for) 102 | {query, args} = build_query(params, caller) 103 | 104 | quote do 105 | defmacro unquote(name)(unquote_splicing(params)) do 106 | unquote(body(sql_name, query, args)) 107 | end 108 | end 109 | end 110 | 111 | defp body(name, query, args) do 112 | fcall = "#{name}(#{query})" 113 | args = Enum.map(args, &{:unquote, [], [&1]}) 114 | 115 | {:quote, [], [[do: {:fragment, [], [fcall | args]}]]} 116 | end 117 | 118 | defp build_query(args, caller) do 119 | query = 120 | "?" 121 | |> List.duplicate(Enum.count(args)) 122 | |> Enum.join(",") 123 | 124 | args = 125 | args 126 | |> Enum.map(fn 127 | {:\\, _, [{_, _, _} = arg, _default]} -> 128 | arg 129 | 130 | {_, _, env} = arg when is_atom(env) -> 131 | arg 132 | 133 | _token -> 134 | raise CompileError, 135 | file: caller.file, 136 | line: caller.line, 137 | description: ~S"only variables and \\ are allowed as arguments in definition header." 138 | end) 139 | 140 | {query, args} 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoFunction.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ecto_function, 7 | version: "1.0.1", 8 | elixir: "~> 1.5", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | name: "Ecto.Function", 12 | description: """ 13 | Simple macro for defining macro that will return `fragment` with SQL function. 14 | 15 | A little bit Xzibit, but fun. 16 | """, 17 | package: package(), 18 | docs: [main: "readme", extras: ["README.md"]] 19 | ] 20 | end 21 | 22 | # Run "mix help deps" to learn about dependencies. 23 | defp deps do 24 | [ 25 | {:ecto, "~> 3.0", only: [:dev, :test]}, 26 | {:ecto_sql, ">= 0.0.0", only: [:dev, :test]}, 27 | {:postgrex, ">= 0.0.0", only: [:dev, :test]}, 28 | {:ex_doc, "~> 0.14", only: :dev, runtime: false}, 29 | {:dialyxir, ">= 0.0.0", only: :dev, runtime: false}, 30 | {:credo, ">= 0.0.0", only: :dev, runtime: false} 31 | ] 32 | end 33 | 34 | defp package do 35 | [ 36 | maintainers: ["Łukasz Jan Niemier"], 37 | files: ["lib", "mix.exs", "README*", "LICENSE*"], 38 | licenses: ["MIT"], 39 | links: %{ 40 | "GitHub" => "https://github.com/hauleth/ecto_function" 41 | } 42 | ] 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/ecto_function_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Functions do 2 | import Ecto.Function 3 | 4 | defqueryfunc cbrt(dp) 5 | 6 | defqueryfunc sqrt / 1 7 | 8 | defqueryfunc regr_syy(y, x \\ 0) 9 | 10 | defqueryfunc regr_x(y \\ 0, x), for: "regr_sxx" 11 | end 12 | 13 | defmodule Ecto.FunctionTest do 14 | use ExUnit.Case 15 | 16 | import ExUnit.CaptureLog 17 | 18 | alias Ecto.Integration.Repo 19 | 20 | doctest Ecto.Function 21 | 22 | setup_all do 23 | Repo.insert_all("example", Enum.map(1..10, &%{value: &1})) 24 | 25 | on_exit(fn -> 26 | Ecto.Adapters.SQL.Sandbox.checkout(Repo) 27 | end) 28 | 29 | :ok 30 | end 31 | 32 | describe "compilation" do 33 | setup do 34 | mod = String.to_atom("Elixir.Test#{System.unique_integer([:positive])}") 35 | 36 | {:ok, mod: mod} 37 | end 38 | 39 | test "with defined macro compiles", %{mod: mod} do 40 | code = """ 41 | import Ecto.Function 42 | 43 | defmodule :'#{mod}' do 44 | defqueryfunc test(a, b) 45 | end 46 | """ 47 | 48 | assert [{^mod, _}] = Code.compile_string(code) 49 | assert macro_exported?(mod, :test, 2) 50 | end 51 | 52 | test "with defined macro using slashed syntax compiles", %{mod: mod} do 53 | code = """ 54 | import Ecto.Function 55 | 56 | defmodule :'#{mod}' do 57 | defqueryfunc test/2 58 | end 59 | """ 60 | 61 | log = 62 | capture_log(fn -> 63 | assert [{^mod, _}] = Code.compile_string(code) 64 | end) 65 | 66 | assert log =~ "func/arity syntax is deprecated" 67 | assert macro_exported?(mod, :test, 2) 68 | end 69 | 70 | test "with default params", %{mod: mod} do 71 | code = """ 72 | import Ecto.Function 73 | 74 | defmodule :'#{mod}' do 75 | defqueryfunc test(a, b \\\\ 1) 76 | end 77 | """ 78 | 79 | assert [{^mod, _}] = Code.compile_string(code) 80 | assert macro_exported?(mod, :test, 1) 81 | assert macro_exported?(mod, :test, 2) 82 | end 83 | 84 | test "generated code" do 85 | result_ast = 86 | Macro.expand_once( 87 | quote do 88 | Ecto.Function.defqueryfunc(test(a, b)) 89 | end, 90 | __ENV__ 91 | ) 92 | 93 | assert {:defmacro, _, macro} = result_ast 94 | assert [{:test, _, [{:a, _, _}, {:b, _, _}]}, [do: body]] = macro 95 | assert {:quote, _, [[do: quoted]]} = body 96 | assert {:fragment, _, [sql_string | params]} = quoted 97 | assert sql_string =~ ~r/test\(\?,\s*\?\)/ 98 | for param <- params, do: assert({:unquote, _, _} = param) 99 | end 100 | 101 | test "do not compiles when params aren't correct", %{mod: mod} do 102 | code = """ 103 | import Ecto.Function 104 | 105 | defmodule :'#{mod}' do 106 | defqueryfunc test(a, foo(funky)) 107 | end 108 | """ 109 | 110 | assert_raise CompileError, 111 | "nofile:4: only variables and \\\\ are allowed as arguments in definition header.", 112 | fn -> 113 | Code.compile_string(code) 114 | end 115 | end 116 | 117 | test "doesn't compile when there is unexpected function definition", %{mod: mod} do 118 | code = """ 119 | import Ecto.Function 120 | 121 | defmodule :'#{mod}' do 122 | defqueryfunc "foo" 123 | end 124 | """ 125 | 126 | assert_raise CompileError, "nofile:4: Unexpected query function definition \"foo\".", fn -> 127 | Code.compile_string(code) 128 | end 129 | end 130 | 131 | test "compile even with no params", %{mod: mod} do 132 | code = """ 133 | import Ecto.Function 134 | 135 | defmodule :'#{mod}' do 136 | defqueryfunc foo 137 | defqueryfunc bar/0 138 | defqueryfunc baz() 139 | end 140 | """ 141 | 142 | _ = 143 | capture_log(fn -> 144 | assert [{^mod, _}] = Code.compile_string(code) 145 | end) 146 | 147 | assert macro_exported?(mod, :foo, 0) 148 | assert macro_exported?(mod, :bar, 0) 149 | assert macro_exported?(mod, :baz, 0) 150 | end 151 | end 152 | 153 | describe "example function defined by params list" do 154 | import Ecto.Query 155 | import Functions 156 | 157 | test "return correct computation" do 158 | result = Repo.all(from(item in "example", select: cbrt(item.value))) 159 | 160 | expected = [ 161 | 1.00000000, 162 | 1.25992104, 163 | 1.44224957, 164 | 1.58740105, 165 | 1.70997594, 166 | 1.81712059, 167 | 1.91293118, 168 | 2.00000000, 169 | 2.08008382, 170 | 2.15443469 171 | ] 172 | 173 | for {res, exp} <- Enum.zip(result, expected) do 174 | assert_in_delta res, exp, 0.0000001 175 | end 176 | end 177 | end 178 | 179 | describe "example function defined by params count" do 180 | import Ecto.Query 181 | import Functions 182 | 183 | test "return correct computation" do 184 | result = Repo.all(from(item in "example", select: sqrt(item.value))) 185 | 186 | expected = [ 187 | 1.00000000, 188 | 1.41421356, 189 | 1.73205080, 190 | 2.00000000, 191 | 2.23606797, 192 | 2.44948974, 193 | 2.64575131, 194 | 2.82842712, 195 | 3.00000000, 196 | 3.16227766 197 | ] 198 | 199 | for {res, exp} <- Enum.zip(result, expected) do 200 | assert_in_delta Decimal.to_float(res), exp, 0.0000001 201 | end 202 | end 203 | end 204 | 205 | describe "example function defined by params list with defaults" do 206 | import Ecto.Query 207 | import Functions 208 | 209 | test "when called with both arguments" do 210 | result = Repo.one!(from(item in "example", select: regr_syy(item.value, 0))) 211 | 212 | assert_in_delta result, 82.5, 0.0000001 213 | end 214 | 215 | test "when called with one argument" do 216 | result = Repo.one!(from(item in "example", select: regr_syy(item.value))) 217 | 218 | assert_in_delta result, 82.5, 0.0000001 219 | end 220 | end 221 | 222 | describe "example function delegated to different name" do 223 | import Ecto.Query 224 | import Functions 225 | 226 | test "when return correct computation" do 227 | result = Repo.one!(from(item in "example", select: regr_x(item.value))) 228 | 229 | assert_in_delta result, 82.5, 0.0000001 230 | end 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /test/support/migration.exs: -------------------------------------------------------------------------------- 1 | defmodule Ecto.Integration.Migration do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:example) do 6 | add :value, :numeric 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | alias Ecto.Integration.Repo 2 | 3 | defmodule Ecto.Integration.Repo do 4 | use Ecto.Repo, otp_app: :ecto_function, adapter: Ecto.Adapters.Postgres 5 | end 6 | 7 | # Load up the repository, start it, and run migrations 8 | _ = Ecto.Adapters.Postgres.storage_down(Repo.config()) 9 | :ok = Ecto.Adapters.Postgres.storage_up(Repo.config()) 10 | 11 | {:ok, _pid} = Repo.start_link() 12 | 13 | Code.require_file("support/migration.exs", __DIR__) 14 | 15 | :ok = Ecto.Migrator.up(Repo, 0, Ecto.Integration.Migration, log: false) 16 | Ecto.Adapters.SQL.Sandbox.mode(Repo, :manual) 17 | Process.flag(:trap_exit, true) 18 | 19 | :ok = Repo.stop() 20 | {:ok, _pid} = Repo.start_link() 21 | 22 | ExUnit.start() 23 | --------------------------------------------------------------------------------