├── test ├── test_helper.exs └── ecto_hooks │ ├── delta_test.exs │ ├── state_test.exs │ └── repo_test.exs ├── .gitignore ├── .envrc ├── .formatter.exs ├── coveralls.json ├── flake.nix ├── LICENSE ├── flake.lock ├── lib ├── ecto_hooks │ ├── state.ex │ └── delta.ex └── ecto_hooks.ex ├── .github └── workflows │ └── pr.yml ├── mix.exs ├── mix.lock └── README.md /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tags 2 | .elixir_ls/ 3 | .env 4 | .envrc 5 | _build/ 6 | erl_crash.dump 7 | deps/ 8 | cover/ 9 | apps/**/cover/ 10 | priv/plts/ 11 | doc/ 12 | .direnv/ 13 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if ! has nix_direnv_version || ! nix_direnv_version 2.4.0; then 4 | source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.4.0/direnvrc" "sha256-XQzUAvL6pysIJnRJyR7uVpmUSZfc7LSgWQwq/4mBr1U=" 5 | fi 6 | 7 | nix_direnv_watch_file flake.nix 8 | use flake 9 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: [ 3 | "mix.exs", 4 | "{config,lib,test,priv}/**/*.{ex,exs}" 5 | ], 6 | locals_without_parens: [ 7 | # Formatter tests 8 | assert_format: 2, 9 | assert_format: 3, 10 | assert_same: 1, 11 | assert_same: 2, 12 | 13 | # Errors tests 14 | assert_eval_raise: 3, 15 | 16 | # Mix tests 17 | in_fixture: 2, 18 | in_tmp: 2 19 | ] 20 | ] 21 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "default_stop_words": [ 3 | "defmodule", 4 | "defrecord", 5 | "defimpl", 6 | "defexception", 7 | "defprotocol", 8 | "defstruct", 9 | "def.+(.+\\\\.+).+do", 10 | "^\\s+use\\s+" 11 | ], 12 | "custom_stop_words": [], 13 | "coverage_options": { 14 | "treat_no_relevant_lines_as_covered": true, 15 | "output_dir": "cover/" 16 | }, 17 | "terminal_options": { "file_column_width": 40 }, 18 | "skip_files": ["lib/ecto_middleware/super.ex"] 19 | } 20 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 | flake-utils.url = "github:numtide/flake-utils"; 5 | }; 6 | 7 | outputs = { self, nixpkgs, flake-utils }: 8 | flake-utils.lib.eachDefaultSystem ( 9 | system: 10 | let 11 | pkgs = import nixpkgs { inherit system; }; 12 | 13 | # `sha256` can be programmatically obtained via running the following: 14 | # `nix-prefetch-url --unpack https://github.com/elixir-lang/elixir/archive/v${version}.tar.gz` 15 | elixir_1_15_7 = (pkgs.beam.packagesWith pkgs.erlangR26).elixir_1_15.override { 16 | version = "1.15.7"; 17 | sha256 = "0yfp16fm8v0796f1rf1m2r0m2nmgj3qr7478483yp1x5rk4xjrz8"; 18 | }; 19 | in 20 | with pkgs; { 21 | devShells.default = mkShell { 22 | buildInputs = [ elixir_1_15_7 inotify-tools ]; 23 | env = { }; 24 | }; 25 | } 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Chris Bailey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1705309234, 9 | "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1708807242, 24 | "narHash": "sha256-sRTRkhMD4delO/hPxxi+XwLqPn8BuUq6nnj4JqLwOu0=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "73de017ef2d18a04ac4bfd0c02650007ccb31c2a", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /lib/ecto_hooks/state.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoHooks.State do 2 | @moduledoc false 3 | 4 | @doc false 5 | def disable_hooks([global: global] \\ [global: true]), 6 | do: put_state({global, :hooks_enabled}, false) 7 | 8 | @doc false 9 | def enable_hooks([global: global] \\ [global: true]), 10 | do: put_state({global, :hooks_enabled}, true) 11 | 12 | @doc """ 13 | Returns a boolean indicating if EctoHooks are enabled in the current process. 14 | 15 | When `true`, hooks will be triggered for Repo operations. 16 | When `false`, hooks are disabled and will not run. 17 | """ 18 | def hooks_enabled? do 19 | if get_state({true, :hooks_enabled}, true) do 20 | get_state({false, :hooks_enabled}, true) 21 | else 22 | false 23 | end 24 | end 25 | 26 | @doc """ 27 | Utility function which returns true if currently executing inside the context of an 28 | Ecto Hook. 29 | """ 30 | def in_hook?, do: get_state(:ref_count, 0) > 0 31 | 32 | @doc """ 33 | Utility function which returns the "nesting" of the current EctoHooks context. 34 | 35 | By default, every hook will "acquire" an EctoHook context and increment a ref count. 36 | These ref counts are automatically decremented once a hook finishes running. 37 | 38 | This is provided as a lower level alternative the `enable_hooks/1`, `disable_hooks/1`, 39 | and `hooks_enabled?/0` functions. 40 | """ 41 | def hooks_ref_count, do: get_state(:ref_count, 0) 42 | 43 | @doc false 44 | def acquire_hook, do: put_state(:ref_count, get_state(:ref_count, 0) + 1) 45 | 46 | @doc false 47 | def release_hook, do: put_state(:ref_count, max(get_state(:ref_count, 0) - 1, 0)) 48 | 49 | # === Helpers for keeping process dictionary keys in our own namespace === 50 | defp put_state(key, value) do 51 | key 52 | |> build_key() 53 | |> Process.put(value) 54 | 55 | :ok 56 | end 57 | 58 | defp get_state(key, default) do 59 | key 60 | |> build_key() 61 | |> Process.get(default) 62 | end 63 | 64 | defp build_key(key), do: {__MODULE__, key} 65 | end 66 | -------------------------------------------------------------------------------- /test/ecto_hooks/delta_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoHooks.DeltaTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias EctoHooks.Delta 5 | 6 | defmodule User do 7 | use Ecto.Schema 8 | 9 | schema "user" do 10 | field(:first_name, :string) 11 | field(:last_name, :string) 12 | end 13 | end 14 | 15 | describe "new!/3" do 16 | for hook <- Delta.hooks(), 17 | repo_callback <- Delta.repo_callbacks() do 18 | test "given callback: `#{repo_callback}` and hook: `#{hook}`, persists accordingly" do 19 | assert %Delta{} = delta = Delta.new!(unquote(repo_callback), unquote(hook), Test) 20 | 21 | assert delta.repo_callback == unquote(repo_callback) 22 | assert delta.hook == unquote(hook) 23 | assert delta.source == Test 24 | 25 | assert is_nil(delta.changeset) 26 | assert is_nil(delta.queryable) 27 | assert is_nil(delta.record) 28 | end 29 | end 30 | 31 | test "raises given invalid callback" do 32 | assert_raise FunctionClauseError, fn -> 33 | Delta.new!(:random, Enum.random(Delta.hooks()), Test) 34 | end 35 | end 36 | 37 | test "raises given invalid hook" do 38 | assert_raise FunctionClauseError, fn -> 39 | Delta.new!(Enum.random(Delta.repo_callbacks()), :random, Test) 40 | end 41 | end 42 | 43 | test "given changeset, sets changeset field" do 44 | changeset = %Ecto.Changeset{} 45 | 46 | assert %Delta{changeset: ^changeset, source: ^changeset} = 47 | Delta.new!(:insert, :after_insert, changeset) 48 | end 49 | 50 | test "given queryable, sets queryable field" do 51 | queryable = %Ecto.Query{} 52 | 53 | assert %Delta{queryable: ^queryable, source: ^queryable} = 54 | Delta.new!(:insert, :after_insert, queryable) 55 | end 56 | 57 | test "given schema struct, sets record field" do 58 | schema_struct = %User{} 59 | 60 | assert %Delta{record: ^schema_struct, source: ^schema_struct} = 61 | Delta.new!(:insert, :after_insert, schema_struct) 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | env: 14 | MIX_ENV: test 15 | 16 | jobs: 17 | lint: 18 | name: Lint 19 | runs-on: ubuntu-latest 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Install Nix 26 | uses: cachix/install-nix-action@v27 27 | with: 28 | nix_path: nixpkgs=channel:nixos-unstable 29 | 30 | - name: Setup Nix cache 31 | uses: DeterminateSystems/magic-nix-cache-action@v7 32 | 33 | - name: Cache Mix dependencies 34 | uses: actions/cache@v4 35 | with: 36 | path: | 37 | deps 38 | _build 39 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 40 | restore-keys: | 41 | ${{ runner.os }}-mix- 42 | 43 | - name: Cache Dialyzer PLT 44 | uses: actions/cache@v4 45 | with: 46 | path: priv/plts 47 | key: ${{ runner.os }}-plt-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/*.ex') }} 48 | restore-keys: | 49 | ${{ runner.os }}-plt-${{ hashFiles('**/mix.lock') }}- 50 | ${{ runner.os }}-plt- 51 | 52 | - name: Install dependencies 53 | run: nix develop --command mix deps.get 54 | 55 | - name: Run linters 56 | run: nix develop --command mix lint 57 | 58 | test: 59 | name: Test 60 | runs-on: ubuntu-latest 61 | 62 | steps: 63 | - name: Checkout code 64 | uses: actions/checkout@v4 65 | 66 | - name: Install Nix 67 | uses: cachix/install-nix-action@v27 68 | with: 69 | nix_path: nixpkgs=channel:nixos-unstable 70 | 71 | - name: Setup Nix cache 72 | uses: DeterminateSystems/magic-nix-cache-action@v7 73 | 74 | - name: Cache Mix dependencies 75 | uses: actions/cache@v4 76 | with: 77 | path: | 78 | deps 79 | _build 80 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 81 | restore-keys: | 82 | ${{ runner.os }}-mix- 83 | 84 | - name: Install dependencies 85 | run: nix develop --command mix deps.get 86 | 87 | - name: Run tests 88 | run: nix develop --command mix test 89 | 90 | - name: Generate coverage report 91 | run: nix develop --command mix coveralls.github 92 | env: 93 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 94 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoHooks.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | aliases: aliases(), 7 | app: :ecto_hooks, 8 | version: "2.0.0", 9 | elixir: "~> 1.13", 10 | elixirc_options: [warnings_as_errors: true], 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | dialyzer: [plt_file: {:no_warn, "priv/plts/dialyzer.plt"}], 14 | preferred_cli_env: [ 15 | test: :test, 16 | "test.watch": :test, 17 | coveralls: :test, 18 | "coveralls.html": :test, 19 | "coveralls.github": :test, 20 | precommit: :test 21 | ], 22 | test_coverage: [tool: ExCoveralls], 23 | package: package(), 24 | description: description(), 25 | source_url: "https://github.com/vereis/ecto_hooks", 26 | docs: docs() 27 | ] 28 | end 29 | 30 | # Run "mix help compile.app" to learn about applications. 31 | def application do 32 | [ 33 | extra_applications: [:logger] 34 | ] 35 | end 36 | 37 | # Run "mix help deps" to learn about dependencies. 38 | defp deps do 39 | [ 40 | # Actual dependencies 41 | {:ecto_middleware, "~> 2.0"}, 42 | 43 | # Lint dependencies 44 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 45 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 46 | 47 | # Test dependencies 48 | {:ecto, "~> 3.10", only: :test, override: true}, 49 | {:etso, "~> 1.1.0", only: :test}, 50 | {:mix_test_watch, "~> 1.1", only: :test, runtime: false}, 51 | {:excoveralls, "~> 0.16", only: :test, runtime: false}, 52 | 53 | # Misc dependencies 54 | {:ex_doc, "~> 0.39", only: :dev, runtime: false} 55 | ] 56 | end 57 | 58 | defp aliases do 59 | [ 60 | lint: [ 61 | "deps.unlock --unused", 62 | "format --check-formatted", 63 | "compile --warnings-as-errors", 64 | "credo --strict", 65 | "dialyzer" 66 | ], 67 | precommit: [ 68 | "deps.unlock --unused", 69 | "format", 70 | "compile --warnings-as-errors", 71 | "credo --strict", 72 | "dialyzer", 73 | "test" 74 | ] 75 | ] 76 | end 77 | 78 | defp description() do 79 | """ 80 | Adds callbacks/hooks to Ecto: `after_insert`, `after_update`, `after_delete`, 81 | `after_get`, `before_insert`, `before_update`, `before_delete`. 82 | 83 | Useful for setting virtual fields and centralising logic. 84 | """ 85 | end 86 | 87 | defp package() do 88 | [ 89 | licenses: ["MIT"], 90 | links: %{ 91 | "GitHub" => "https://github.com/vereis/ecto_hooks" 92 | } 93 | ] 94 | end 95 | 96 | defp docs do 97 | [ 98 | main: "readme", 99 | extras: [ 100 | "README.md" 101 | ] 102 | ] 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /test/ecto_hooks/state_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoHooks.StateTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias EctoHooks.State 5 | 6 | describe "disable_hooks/1" do 7 | test "when already enabled, disables hooks such that `hooks_enabled?/0` returns false" do 8 | assert State.hooks_enabled?() 9 | assert :ok = State.disable_hooks(global: false) 10 | refute State.hooks_enabled?() 11 | end 12 | 13 | test "disabling hooks multiple times is a noop" do 14 | assert State.hooks_enabled?() 15 | assert :ok = State.disable_hooks(global: false) 16 | assert :ok = State.disable_hooks(global: false) 17 | refute State.hooks_enabled?() 18 | end 19 | end 20 | 21 | describe "enable_hooks/1" do 22 | test "when already enabled, disables hooks such that `hooks_enabled?/0` returns false" do 23 | assert :ok = State.disable_hooks(global: false) 24 | refute State.hooks_enabled?() 25 | assert :ok = State.enable_hooks(global: false) 26 | assert State.hooks_enabled?() 27 | end 28 | 29 | test "enabling hooks multiple times is a noop" do 30 | assert State.hooks_enabled?() 31 | assert :ok = State.enable_hooks(global: false) 32 | assert :ok = State.enable_hooks(global: false) 33 | assert State.hooks_enabled?() 34 | end 35 | end 36 | 37 | describe "hooks_enabled?/0" do 38 | test "returns true if hooks enabled" do 39 | assert :ok = State.enable_hooks(global: false) 40 | assert State.hooks_enabled?() 41 | end 42 | 43 | test "returns false if hooks disabled" do 44 | assert :ok = State.disable_hooks(global: false) 45 | refute State.hooks_enabled?() 46 | end 47 | end 48 | 49 | describe "acquire_hook/0" do 50 | test "increments ref count" do 51 | refute State.in_hook?() 52 | assert 0 = State.hooks_ref_count() 53 | 54 | assert State.acquire_hook() 55 | assert State.in_hook?() 56 | assert 1 = State.hooks_ref_count() 57 | 58 | assert State.acquire_hook() 59 | assert State.in_hook?() 60 | assert 2 = State.hooks_ref_count() 61 | end 62 | end 63 | 64 | describe "release_hook/0" do 65 | test "increments ref count" do 66 | refute State.in_hook?() 67 | assert 0 = State.hooks_ref_count() 68 | 69 | assert State.acquire_hook() 70 | assert State.in_hook?() 71 | assert 1 = State.hooks_ref_count() 72 | 73 | assert State.acquire_hook() 74 | assert State.in_hook?() 75 | assert 2 = State.hooks_ref_count() 76 | 77 | assert State.release_hook() 78 | assert State.in_hook?() 79 | assert 1 = State.hooks_ref_count() 80 | 81 | assert State.release_hook() 82 | refute State.in_hook?() 83 | assert 0 = State.hooks_ref_count() 84 | end 85 | end 86 | 87 | describe "hooks_ref_count/0" do 88 | test "returns current nesting for hooks" do 89 | refute State.in_hook?() 90 | assert 0 = State.hooks_ref_count() 91 | 92 | assert State.acquire_hook() 93 | assert State.in_hook?() 94 | assert 1 = State.hooks_ref_count() 95 | 96 | assert State.release_hook() 97 | refute State.in_hook?() 98 | assert 0 = State.hooks_ref_count() 99 | end 100 | end 101 | 102 | describe "in_hook?/0" do 103 | test "returns false when no hooks have been acquired" do 104 | refute State.in_hook?() 105 | end 106 | 107 | test "returns true when hooks have been acquired" do 108 | assert State.acquire_hook() 109 | assert State.in_hook?() 110 | end 111 | end 112 | end 113 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.5", "643213503b1c766ec0496d828c90c424471ea54da77c8a168c725686377b9545", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "f799e9b5cd1891577d8c773d245668aa74a2fcd15eb277f51a0131690ebfb3fd"}, 4 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 5 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 7 | "ecto": {:hex, :ecto, "3.11.1", "4b4972b717e7ca83d30121b12998f5fcdc62ba0ed4f20fd390f16f3270d85c3e", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ebd3d3772cd0dfcd8d772659e41ed527c28b2a8bde4b00fe03e0463da0f1983b"}, 8 | "ecto_middleware": {:hex, :ecto_middleware, "2.0.0", "53cee83cedd0c767e60792e736276b1df83c4fb89d665ab34123bdece5f34422", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ab0eb4466247145eceb1b68b35aff91207899dc313a17cef7788d20f9cb93515"}, 9 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 10 | "etso": {:hex, :etso, "1.1.0", "ddbf5417522ecc5f9544a5daeb67fc5f7509a5edb7f65add85a530dc35f80ec5", [:mix], [{:ecto, "~> 3.8.3", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "aa74f6bd76fb444aaa94554c668d637eedd6d71c0a9887ef973437ebe6645368"}, 11 | "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, 12 | "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, 13 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 14 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 15 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 18 | "mix_test_watch": {:hex, :mix_test_watch, "1.1.2", "431bdccf20b110f1595fe2a0e3c6cffd96d8f706721def5d04d557bc0898c476", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "8ce79fc69a304eec81ab6c1a05de2eb026a8959f65fb47f933ce8eb56018ba35"}, 19 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 20 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 21 | } 22 | -------------------------------------------------------------------------------- /lib/ecto_hooks/delta.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoHooks.Delta do 2 | @moduledoc """ 3 | Metadata struct passed to `after_*` hooks containing information about the operation. 4 | 5 | The Delta struct provides context about which repository operation triggered the hook, 6 | allowing for conditional logic and introspection within your hooks. 7 | 8 | ## Fields 9 | 10 | - `:repo_callback` - The `c:Ecto.Repo` function that was called (e.g., `:insert` for `c:Ecto.Repo.insert/2`, `:get` for `c:Ecto.Repo.get/3`) 11 | - `:hook` - The hook currently executing (e.g., `:after_insert`, `:after_get`) 12 | - `:source` - The original input to the repo operation (changeset, queryable, or struct) 13 | - `:queryable` - Set if source is an `t:Ecto.Queryable.t/0` (query or schema module) 14 | - `:changeset` - Set if source is an `t:Ecto.Changeset.t/0` 15 | - `:record` - Set if source is a schema struct or list 16 | 17 | ## Examples 18 | 19 | ### Conditional Hook Logic 20 | 21 | def after_get(user, %Delta{repo_callback: :get}) do 22 | # Only log when fetching single users via c:Ecto.Repo.get/3 23 | # (not during c:Ecto.Repo.all/2) 24 | Logger.info("Fetched user: \#{user.id}") 25 | user 26 | end 27 | 28 | def after_get(user, %Delta{repo_callback: :all}) do 29 | # Skip logging for bulk fetches via c:Ecto.Repo.all/2 30 | user 31 | end 32 | 33 | ### Inspecting Changes 34 | 35 | def after_update(user, %Delta{changeset: changeset}) do 36 | if Ecto.Changeset.changed?(changeset, :email) do 37 | EmailVerification.send_confirmation(user) 38 | end 39 | user 40 | end 41 | 42 | ### Delegating to Private Functions 43 | 44 | def after_insert(user, delta), do: handle_after_hook(user, delta) 45 | def after_update(user, delta), do: handle_after_hook(user, delta) 46 | 47 | defp handle_after_hook(user, %Delta{hook: :after_insert}) do 48 | # Logic specific to inserts 49 | send_welcome_email(user) 50 | user 51 | end 52 | 53 | defp handle_after_hook(user, %Delta{hook: :after_update}) do 54 | # Logic specific to updates 55 | invalidate_cache(user) 56 | user 57 | end 58 | 59 | ## Type 60 | 61 | The Delta struct is defined as: 62 | 63 | @type t :: %__MODULE__{ 64 | repo_callback: repo_callback(), 65 | hook: hook(), 66 | source: term(), 67 | queryable: Ecto.Queryable.t() | nil, 68 | changeset: Ecto.Changeset.t() | nil, 69 | record: struct() | [struct()] | nil 70 | } 71 | 72 | Where `repo_callback()` is one of: 73 | - `:all` (`c:Ecto.Repo.all/2`) 74 | - `:delete` (`c:Ecto.Repo.delete/2`) 75 | - `:delete!` (`c:Ecto.Repo.delete!/2`) 76 | - `:get` (`c:Ecto.Repo.get/3`) 77 | - `:get!` (`c:Ecto.Repo.get!/3`) 78 | - `:get_by` (`c:Ecto.Repo.get_by/3`) 79 | - `:get_by!` (`c:Ecto.Repo.get_by!/3`) 80 | - `:insert` (`c:Ecto.Repo.insert/2`) 81 | - `:insert!` (`c:Ecto.Repo.insert!/2`) 82 | - `:insert_or_update` (`c:Ecto.Repo.insert_or_update/2`) 83 | - `:insert_or_update!` (`c:Ecto.Repo.insert_or_update!/2`) 84 | - `:one` (`c:Ecto.Repo.one/2`) 85 | - `:one!` (`c:Ecto.Repo.one!/2`) 86 | - `:preload` (`c:Ecto.Repo.preload/3`) 87 | - `:reload` (`c:Ecto.Repo.reload/2`) 88 | - `:reload!` (`c:Ecto.Repo.reload!/2`) 89 | - `:update` (`c:Ecto.Repo.update/2`) 90 | - `:update!` (`c:Ecto.Repo.update!/2`) 91 | 92 | And `hook()` is one of: `:after_delete`, `:after_get`, `:after_insert`, `:after_update`, 93 | `:before_delete`, `:before_insert`, `:before_update` 94 | """ 95 | 96 | alias __MODULE__ 97 | 98 | @type t :: %__MODULE__{} 99 | 100 | @enforce_keys [:repo_callback, :hook, :source] 101 | defstruct [:repo_callback, :hook, :source, :queryable, :changeset, :record] 102 | 103 | @repo_callbacks [ 104 | :all, 105 | :delete!, 106 | :delete, 107 | :get!, 108 | :get, 109 | :get_by!, 110 | :get_by, 111 | :insert!, 112 | :insert, 113 | :insert_or_update!, 114 | :insert_or_update, 115 | :one!, 116 | :one, 117 | :reload!, 118 | :reload, 119 | :preload, 120 | :update!, 121 | :update 122 | ] 123 | 124 | @hooks [ 125 | :after_delete, 126 | :after_get, 127 | :after_insert, 128 | :after_update, 129 | :before_delete, 130 | :before_insert, 131 | :before_update 132 | ] 133 | 134 | @type repo_callback :: unquote(Enum.reduce(@repo_callbacks, &{:|, [], [&1, &2]})) 135 | @type hook :: unquote(Enum.reduce(@hooks, &{:|, [], [&1, &2]})) 136 | 137 | @spec new!(repo_callback(), hook(), source :: any()) :: __MODULE__.t() | no_return() 138 | # NOTE: The `cond` pattern is nicer than doing this with other 139 | # branching constructs, but leads to a high cyclomatic complexity. 140 | # This *could* be done via multiple function heads, but I found 141 | # that to be less readable. 142 | # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity 143 | def new!(repo_callback, hook, source) 144 | when repo_callback in @repo_callbacks and hook in @hooks do 145 | delta = %Delta{repo_callback: repo_callback, hook: hook, source: source} 146 | 147 | cond do 148 | match?(%{__struct__: Ecto.Changeset}, source) -> 149 | %Delta{delta | changeset: source} 150 | 151 | match?(%{__struct__: Ecto.Query}, source) -> 152 | %Delta{delta | queryable: source} 153 | 154 | is_atom(source) && function_exported?(source, :__schema__, 2) -> 155 | %Delta{delta | queryable: source} 156 | 157 | is_struct(source) && function_exported?(source.__struct__, :__schema__, 2) -> 158 | %Delta{delta | record: source} 159 | 160 | is_list(source) -> 161 | %Delta{delta | record: source} 162 | 163 | true -> 164 | delta 165 | end 166 | end 167 | 168 | @doc false 169 | def repo_callbacks do 170 | @repo_callbacks 171 | end 172 | 173 | @doc false 174 | def hooks do 175 | @hooks 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | EctoHooks 3 |

4 | 5 | # EctoHooks 6 | 7 | [![Hex Version](https://img.shields.io/hexpm/v/ecto_hooks.svg)](https://hex.pm/packages/ecto_hooks) 8 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/ecto_hooks/) 9 | [![CI Status](https://github.com/vereis/ecto_hooks/workflows/CI/badge.svg)](https://github.com/vereis/ecto_hooks/actions) 10 | [![Coverage Status](https://coveralls.io/repos/github/vereis/ecto_hooks/badge.svg?branch=main)](https://coveralls.io/github/vereis/ecto_hooks?branch=main) 11 | 12 | > Add `before_*` and `after_*` callbacks to your Ecto schemas, similar to the old `Ecto.Model` callbacks. 13 | 14 | ## Installation 15 | 16 | Add `:ecto_hooks` to the list of dependencies in `mix.exs`: 17 | 18 | ```elixir 19 | def deps do 20 | [ 21 | {:ecto_hooks, "~> 2.0"} 22 | ] 23 | end 24 | ``` 25 | 26 | > **Note:** EctoHooks is built on top of [EctoMiddleware](https://hex.pm/packages/ecto_middleware) 27 | > and includes it as a dependency. Installing `ecto_hooks` gives you everything you need - no 28 | > additional dependencies required! 29 | 30 | ## Quick Start 31 | 32 | ### 1. Setup Your Repo 33 | 34 | Add `EctoHooks` to your Repo's middleware pipeline. Since `EctoMiddleware` is included 35 | with `ecto_hooks`, you can use it directly: 36 | 37 | ```elixir 38 | defmodule MyApp.Repo do 39 | use Ecto.Repo, 40 | otp_app: :my_app, 41 | adapter: Ecto.Adapters.Postgres 42 | 43 | use EctoMiddleware.Repo # Comes with ecto_hooks! 44 | 45 | @impl EctoMiddleware.Repo 46 | def middleware(_action, _resource) do 47 | [EctoHooks] 48 | end 49 | end 50 | ``` 51 | 52 | ### 2. Define Hooks in Your Schemas 53 | 54 | ```elixir 55 | defmodule MyApp.User do 56 | use Ecto.Schema 57 | require Logger 58 | 59 | schema "users" do 60 | field :first_name, :string 61 | field :last_name, :string 62 | field :full_name, :string, virtual: true 63 | end 64 | 65 | @impl EctoHooks 66 | def before_insert(changeset) do 67 | Logger.info("Inserting new user") 68 | changeset 69 | end 70 | 71 | @impl EctoHooks 72 | def after_get(%__MODULE__{} = user, %EctoHooks.Delta{}) do 73 | %{user | full_name: "#{user.first_name} #{user.last_name}"} 74 | end 75 | end 76 | ``` 77 | 78 | That's it! The hooks will be called automatically whenever you use your Repo. 79 | 80 | ## Available Hooks 81 | 82 | ### Before Hooks (arity 1) 83 | 84 | Transform data **before** it reaches the database: 85 | 86 | - `before_insert/1` - Called before `insert/2`, `insert!/2`, and `insert_or_update/2` (for new records) 87 | - `before_update/1` - Called before `update/2`, `update!/2`, and `insert_or_update/2` (for existing records) 88 | - `before_delete/1` - Called before `delete/2`, `delete!/2` 89 | 90 | ```elixir 91 | @impl EctoHooks 92 | def before_insert(changeset) do 93 | # Normalize email before saving 94 | case Ecto.Changeset.fetch_change(changeset, :email) do 95 | {:ok, email} -> Ecto.Changeset.put_change(changeset, :email, String.downcase(email)) 96 | :error -> changeset 97 | end 98 | end 99 | ``` 100 | 101 | ### After Hooks (arity 2) 102 | 103 | Process data **after** database operations: 104 | 105 | - `after_get/2` - Called after `get/3`, `get!/3`, `all/2`, `one/2`, `reload/2`, `preload/3`, etc. 106 | - `after_insert/2` - Called after `insert/2`, `insert!/2`, and `insert_or_update/2` (for new records) 107 | - `after_update/2` - Called after `update/2`, `update!/2`, and `insert_or_update/2` (for existing records) 108 | - `after_delete/2` - Called after `delete/2`, `delete!/2` 109 | 110 | All after hooks receive a `%EctoHooks.Delta{}` struct with metadata about the operation: 111 | 112 | ```elixir 113 | @impl EctoHooks 114 | def after_get(%__MODULE__{} = user, %EctoHooks.Delta{} = delta) do 115 | # delta.repo_callback - Which repo function was called (:get, :all, etc.) 116 | # delta.hook - Which hook is executing (:after_get, etc.) 117 | # delta.source - The original queryable/changeset/struct 118 | 119 | %{user | full_name: "#{user.first_name} #{user.last_name}"} 120 | end 121 | ``` 122 | 123 | ## How It Works 124 | 125 | EctoHooks is built on top of [EctoMiddleware](https://hex.pm/packages/ecto_middleware), which provides a middleware pipeline pattern for Ecto operations (similar to Plug or Absinthe middleware). 126 | 127 | When you add `EctoHooks` to your middleware pipeline, it: 128 | 1. Intercepts Repo operations 129 | 2. Calls the appropriate `before_*` hook on your schema (if defined) 130 | 3. Executes the actual database operation 131 | 4. Calls the appropriate `after_*` hook on the result (if defined) 132 | 5. Returns the final result 133 | 134 | All hooks are **optional** - if you don't define a hook, it simply doesn't run. 135 | 136 | ## Why Hooks? 137 | 138 | ### Centralize Virtual Field Logic 139 | 140 | Instead of setting virtual fields in every controller/context function: 141 | 142 | ```elixir 143 | # Without hooks - scattered across codebase 144 | def get_user(id) do 145 | user = Repo.get!(User, id) 146 | %{user | full_name: "#{user.first_name} #{user.last_name}"} 147 | end 148 | 149 | def list_users do 150 | User 151 | |> Repo.all() 152 | |> Enum.map(fn user -> 153 | %{user | full_name: "#{user.first_name} #{user.last_name}"} 154 | end) 155 | end 156 | ``` 157 | 158 | With hooks, it happens automatically: 159 | 160 | ```elixir 161 | # With hooks - defined once in the schema 162 | @impl EctoHooks 163 | def after_get(user, _delta) do 164 | %{user | full_name: "#{user.first_name} #{user.last_name}"} 165 | end 166 | 167 | # Now these just work 168 | Repo.get!(User, id) # full_name set automatically 169 | Repo.all(User) # full_name set for all users 170 | ``` 171 | 172 | ### Audit Logging 173 | 174 | ```elixir 175 | @impl EctoHooks 176 | def after_insert(user, delta) do 177 | AuditLog.log("user_created", user.id, delta.source) 178 | user 179 | end 180 | 181 | @impl EctoHooks 182 | def after_update(user, delta) do 183 | AuditLog.log("user_updated", user.id, delta.source) 184 | user 185 | end 186 | ``` 187 | 188 | ### Data Normalization 189 | 190 | ```elixir 191 | @impl EctoHooks 192 | def before_insert(changeset) do 193 | changeset 194 | |> normalize_email() 195 | |> trim_strings() 196 | |> set_defaults() 197 | end 198 | ``` 199 | 200 | ## Controlling Hook Execution 201 | 202 | Sometimes you need to disable hooks (e.g., to prevent infinite loops or for bulk operations): 203 | 204 | ```elixir 205 | # Disable hooks for current process 206 | EctoHooks.disable_hooks() 207 | Repo.insert!(user) # Hooks won't run 208 | 209 | # Re-enable hooks 210 | EctoHooks.enable_hooks() 211 | 212 | # Check if hooks are enabled 213 | EctoHooks.hooks_enabled?() #=> true 214 | 215 | # Check if currently inside a hook 216 | EctoHooks.in_hook?() #=> false 217 | ``` 218 | 219 | **Note:** EctoHooks automatically prevents infinite loops by disabling hooks while executing a hook. This means if a hook calls another Repo operation, that operation won't trigger its own hooks. 220 | 221 | ## Telemetry Events 222 | 223 | EctoHooks is built on [EctoMiddleware](https://hex.pm/packages/ecto_middleware), which emits telemetry events for observability. You can attach handlers to monitor hook execution performance and behavior. 224 | 225 | ### Available Events 226 | 227 | **Pipeline Events:** 228 | - `[:ecto_middleware, :pipeline, :start]` - Hook pipeline execution starts 229 | - `[:ecto_middleware, :pipeline, :stop]` - Hook pipeline execution completes 230 | - `[:ecto_middleware, :pipeline, :exception]` - Hook pipeline execution fails 231 | 232 | **Middleware Events:** 233 | - `[:ecto_middleware, :middleware, :start]` - Individual hook starts (middleware is `EctoHooks`) 234 | - `[:ecto_middleware, :middleware, :stop]` - Individual hook completes 235 | - `[:ecto_middleware, :middleware, :exception]` - Individual hook fails 236 | 237 | ### Example: Monitoring Hook Performance 238 | 239 | ```elixir 240 | :telemetry.attach( 241 | "log-slow-hooks", 242 | [:ecto_middleware, :middleware, :stop], 243 | fn _event, %{duration: duration}, %{middleware: EctoHooks}, _config -> 244 | if duration > 5_000_000 do # 5ms 245 | Logger.warning("Slow hook execution took #{duration}ns") 246 | end 247 | end, 248 | nil 249 | ) 250 | ``` 251 | 252 | ### Example: Tracking Hook Failures 253 | 254 | ```elixir 255 | :telemetry.attach( 256 | "track-hook-errors", 257 | [:ecto_middleware, :middleware, :exception], 258 | fn _event, measurements, %{middleware: EctoHooks, kind: kind, reason: reason}, _config -> 259 | Logger.error("Hook failed: #{inspect(kind)} - #{inspect(reason)}") 260 | end, 261 | nil 262 | ) 263 | ``` 264 | 265 | For complete telemetry documentation, see the [EctoMiddleware Telemetry Guide](https://hexdocs.pm/ecto_middleware#telemetry). 266 | 267 | ## Migration from v1.x 268 | 269 | EctoHooks v2.0 simplifies the setup significantly: 270 | 271 | **Before (v1.x):** 272 | ```elixir 273 | defmodule MyApp.Repo do 274 | use EctoHooks.Repo, # or use EctoHooks 275 | otp_app: :my_app, 276 | adapter: Ecto.Adapters.Postgres 277 | end 278 | ``` 279 | 280 | **After (v2.0):** 281 | ```elixir 282 | defmodule MyApp.Repo do 283 | use Ecto.Repo, 284 | otp_app: :my_app, 285 | adapter: Ecto.Adapters.Postgres 286 | 287 | use EctoMiddleware.Repo 288 | 289 | @impl EctoMiddleware.Repo 290 | def middleware(_action, _resource) do 291 | [EctoHooks] # Can add other middleware here too! 292 | end 293 | end 294 | ``` 295 | 296 | Hook definitions in schemas remain unchanged - all your existing hooks will continue to work. 297 | 298 | ## Links 299 | 300 | - [hex.pm package](https://hex.pm/packages/ecto_hooks) 301 | - [Online documentation](https://hexdocs.pm/ecto_hooks) 302 | - [EctoMiddleware](https://hex.pm/packages/ecto_middleware) - The middleware engine powering EctoHooks 303 | 304 | ## License 305 | 306 | MIT License. See [LICENSE](LICENSE) for details. 307 | -------------------------------------------------------------------------------- /lib/ecto_hooks.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoHooks do 2 | @moduledoc """ 3 | Middleware for adding `before_*` and `after_*` callbacks to Ecto schemas. 4 | 5 | EctoHooks brings back the convenience of `Ecto.Model` callbacks (removed in Ecto 2.0) 6 | using the modern `EctoMiddleware` pipeline pattern. Perfect for centralizing virtual 7 | field logic, audit logging, data normalization, and other cross-cutting concerns. 8 | 9 | > **Built on EctoMiddleware:** EctoHooks is powered by [EctoMiddleware](https://hex.pm/packages/ecto_middleware), 10 | > which is included as a dependency. Installing `ecto_hooks` gives you everything you need! 11 | 12 | ## Quick Example 13 | 14 | defmodule MyApp.User do 15 | use Ecto.Schema 16 | 17 | schema "users" do 18 | field :first_name, :string 19 | field :last_name, :string 20 | field :email, :string 21 | field :full_name, :string, virtual: true 22 | end 23 | 24 | # c:EctoHooks.before_insert/1 - called before c:Ecto.Repo.insert/2 25 | @impl EctoHooks 26 | def before_insert(changeset) do 27 | # Normalize email before saving 28 | case Ecto.Changeset.fetch_change(changeset, :email) do 29 | {:ok, email} -> 30 | Ecto.Changeset.put_change(changeset, :email, String.downcase(email)) 31 | :error -> 32 | changeset 33 | end 34 | end 35 | 36 | # c:EctoHooks.after_get/2 - called after c:Ecto.Repo.get/3, c:Ecto.Repo.all/2, etc. 37 | @impl EctoHooks 38 | def after_get(%__MODULE__{} = user, %EctoHooks.Delta{}) do 39 | # Populate virtual fields after fetching 40 | %{user | full_name: "\#{user.first_name} \#{user.last_name}"} 41 | end 42 | end 43 | 44 | defmodule MyApp.Repo do 45 | use Ecto.Repo, otp_app: :my_app 46 | use EctoMiddleware.Repo 47 | 48 | @impl EctoMiddleware.Repo 49 | def middleware(_action, _resource) do 50 | [EctoHooks] 51 | end 52 | end 53 | 54 | # Hooks run automatically! 55 | MyApp.Repo.get!(MyApp.User, 1) 56 | #=> %MyApp.User{first_name: "Alice", last_name: "Smith", full_name: "Alice Smith"} 57 | 58 | ## Setup 59 | 60 | Add `EctoHooks` to your Repo's middleware pipeline. EctoHooks includes `EctoMiddleware` 61 | as a dependency, so you can use it directly: 62 | 63 | defmodule MyApp.Repo do 64 | use Ecto.Repo, otp_app: :my_app 65 | use EctoMiddleware.Repo # Comes with ecto_hooks! 66 | 67 | @impl EctoMiddleware.Repo 68 | def middleware(_action, _resource) do 69 | [EctoHooks] # Add alongside other middleware if needed 70 | end 71 | end 72 | 73 | All hooks are **optional** - if you don't define a hook, it simply doesn't run. 74 | 75 | ## Available Hooks 76 | 77 | ### Before Hooks (arity 1) 78 | 79 | Transform data **before** it reaches the database: 80 | 81 | - `c:before_insert/1` - Called before `c:Ecto.Repo.insert/2`, `c:Ecto.Repo.insert!/2`, and `c:Ecto.Repo.insert_or_update/2` (for new records) 82 | - `c:before_update/1` - Called before `c:Ecto.Repo.update/2`, `c:Ecto.Repo.update!/2`, and `c:Ecto.Repo.insert_or_update/2` (for existing records) 83 | - `c:before_delete/1` - Called before `c:Ecto.Repo.delete/2`, `c:Ecto.Repo.delete!/2` 84 | 85 | Before hooks receive the changeset/struct and must return a changeset/struct: 86 | 87 | # c:before_insert/1 example 88 | @impl EctoHooks 89 | def before_insert(changeset) do 90 | changeset 91 | |> normalize_email() 92 | |> set_defaults() 93 | |> add_timestamps() 94 | end 95 | 96 | ### After Hooks (arity 2) 97 | 98 | Process data **after** database operations: 99 | 100 | - `c:after_get/2` - Called after `c:Ecto.Repo.get/3`, `c:Ecto.Repo.get!/3`, `c:Ecto.Repo.get_by/3`, `c:Ecto.Repo.all/2`, `c:Ecto.Repo.one/2`, `c:Ecto.Repo.reload/2`, `c:Ecto.Repo.preload/3`, etc. 101 | - `c:after_insert/2` - Called after `c:Ecto.Repo.insert/2`, `c:Ecto.Repo.insert!/2`, and `c:Ecto.Repo.insert_or_update/2` (for new records) 102 | - `c:after_update/2` - Called after `c:Ecto.Repo.update/2`, `c:Ecto.Repo.update!/2`, and `c:Ecto.Repo.insert_or_update/2` (for existing records) 103 | - `c:after_delete/2` - Called after `c:Ecto.Repo.delete/2`, `c:Ecto.Repo.delete!/2` 104 | 105 | After hooks receive the struct and a `t:EctoHooks.Delta.t/0` with metadata: 106 | 107 | # c:after_get/2 example 108 | @impl EctoHooks 109 | def after_get(%__MODULE__{} = user, %EctoHooks.Delta{} = delta) do 110 | # delta.repo_callback - Which repo function was called (:get, :all, etc.) 111 | # delta.hook - Which hook is executing (:after_get) 112 | # delta.source - The original queryable/changeset/struct 113 | 114 | %{user | full_name: "\#{user.first_name} \#{user.last_name}"} 115 | end 116 | 117 | ## Hook Execution Flow 118 | 119 | For write operations (insert/update/delete): 120 | 121 | 1. Call `c:before_*` hook on changeset/struct 122 | 2. Execute database operation 123 | 3. Call `c:after_*` hook on result 124 | 4. Return final result 125 | 126 | For read operations (get/all/one): 127 | 128 | 1. Execute database query 129 | 2. Call `c:after_get/2` on result(s) 130 | 3. Return transformed result(s) 131 | 132 | Hooks are applied to **all matching records**. For example, `c:Ecto.Repo.all/2` will 133 | call `c:after_get/2` on every record in the result list. 134 | 135 | ## Controlling Execution 136 | 137 | ### Preventing Infinite Loops 138 | 139 | EctoHooks automatically prevents infinite loops by disabling hooks while executing a hook. 140 | If a hook calls another Repo operation, that operation won't trigger its own hooks: 141 | 142 | @impl EctoHooks 143 | def after_insert(user, _delta) do 144 | # This update won't trigger c:before_update/1 or c:after_update/2 hooks 145 | Repo.update!(User.changeset(user, %{last_login: DateTime.utc_now()})) 146 | user 147 | end 148 | 149 | ### Manual Control 150 | 151 | You can manually disable/enable hooks for the current process: 152 | 153 | # Disable hooks 154 | EctoHooks.disable_hooks() 155 | Repo.insert!(user) # Won't trigger hooks 156 | 157 | # Re-enable hooks 158 | EctoHooks.enable_hooks() 159 | 160 | Use `hooks_enabled?/0` and `in_hook?/0` to check current state: 161 | 162 | EctoHooks.hooks_enabled?() #=> true 163 | EctoHooks.in_hook?() #=> false 164 | 165 | ## Use Cases 166 | 167 | ### Virtual Fields 168 | 169 | Centralize virtual field logic instead of scattering it across your codebase with `c:after_get/2`: 170 | 171 | @impl EctoHooks 172 | def after_get(user, _delta) do 173 | %{user | full_name: "\#{user.first_name} \#{user.last_name}"} 174 | end 175 | 176 | ### Audit Logging 177 | 178 | Use `c:after_update/2` to track changes: 179 | 180 | @impl EctoHooks 181 | def after_update(user, delta) do 182 | AuditLog.log("user_updated", user.id, delta.source) 183 | user 184 | end 185 | 186 | ### Data Normalization 187 | 188 | Use `c:before_insert/1` to normalize data before saving: 189 | 190 | @impl EctoHooks 191 | def before_insert(changeset) do 192 | changeset 193 | |> normalize_email() 194 | |> trim_whitespace() 195 | |> set_defaults() 196 | end 197 | 198 | ### Telemetry Events 199 | 200 | Use `c:after_insert/2` to emit events: 201 | 202 | @impl EctoHooks 203 | def after_insert(user, _delta) do 204 | :telemetry.execute([:myapp, :user, :created], %{}, %{user_id: user.id}) 205 | user 206 | end 207 | 208 | ## Considerations 209 | 210 | - Hooks run **synchronously** - expensive operations may slow down queries 211 | - Hooks run for **every operation** - keep them lightweight 212 | - Consider using background jobs for heavy processing 213 | - Be careful with Repo calls inside hooks (see "Preventing Infinite Loops") 214 | 215 | ## Telemetry Events 216 | 217 | EctoHooks is built on [EctoMiddleware](https://hex.pm/packages/ecto_middleware), 218 | which emits telemetry events for observability. Attach handlers to monitor hook 219 | execution performance and behavior. 220 | 221 | ### Available Events 222 | 223 | **Pipeline Events:** 224 | - `[:ecto_middleware, :pipeline, :start]` - Hook pipeline execution starts 225 | - `[:ecto_middleware, :pipeline, :stop]` - Hook pipeline execution completes 226 | - `[:ecto_middleware, :pipeline, :exception]` - Hook pipeline execution fails 227 | 228 | **Middleware Events:** 229 | - `[:ecto_middleware, :middleware, :start]` - Individual hook starts (middleware is `EctoHooks`) 230 | - `[:ecto_middleware, :middleware, :stop]` - Individual hook completes 231 | - `[:ecto_middleware, :middleware, :exception]` - Individual hook fails 232 | 233 | ### Example: Monitoring Hook Performance 234 | 235 | :telemetry.attach( 236 | "log-slow-hooks", 237 | [:ecto_middleware, :middleware, :stop], 238 | fn _event, %{duration: duration}, %{middleware: EctoHooks}, _config -> 239 | if duration > 5_000_000 do # 5ms 240 | Logger.warning("Slow hook execution took \#{duration}ns") 241 | end 242 | end, 243 | nil 244 | ) 245 | 246 | ### Example: Tracking Hook Failures 247 | 248 | :telemetry.attach( 249 | "track-hook-errors", 250 | [:ecto_middleware, :middleware, :exception], 251 | fn _event, _measurements, %{middleware: EctoHooks, kind: kind, reason: reason}, _config -> 252 | Logger.error("Hook failed: \#{inspect(kind)} - \#{inspect(reason)}") 253 | end, 254 | nil 255 | ) 256 | 257 | For complete telemetry documentation, see the [EctoMiddleware Telemetry Guide](https://hexdocs.pm/ecto_middleware#telemetry). 258 | 259 | ## Migration from v1.x 260 | 261 | EctoHooks v2.0 simplifies setup by leveraging `EctoMiddleware.Repo` directly. 262 | 263 | ### What Changed 264 | 265 | **v1.x Setup:** 266 | 267 | defmodule MyApp.Repo do 268 | use EctoHooks.Repo, 269 | otp_app: :my_app, 270 | adapter: Ecto.Adapters.Postgres 271 | end 272 | 273 | **v2.0 Setup:** 274 | 275 | defmodule MyApp.Repo do 276 | use Ecto.Repo, 277 | otp_app: :my_app, 278 | adapter: Ecto.Adapters.Postgres 279 | 280 | use EctoMiddleware.Repo 281 | 282 | @impl EctoMiddleware.Repo 283 | def middleware(_action, _resource) do 284 | [EctoHooks] 285 | end 286 | end 287 | 288 | ### Migration Steps 289 | 290 | 1. Replace `use EctoHooks.Repo` with `use Ecto.Repo` 291 | 2. Add `use EctoMiddleware.Repo` (already included with `ecto_hooks`) 292 | 3. Implement the `c:EctoMiddleware.Repo.middleware/2` callback returning `[EctoHooks]` 293 | 294 | ### What Stays the Same 295 | 296 | **All hook definitions remain unchanged!** Your existing `c:before_insert/1`, `c:before_update/1`, 297 | `c:before_delete/1`, `c:after_get/2`, `c:after_insert/2`, `c:after_update/2`, and `c:after_delete/2` 298 | hooks in schemas will continue to work without modification: 299 | 300 | # These work exactly the same in v2.0 301 | @impl EctoHooks 302 | def before_insert(changeset), do: changeset 303 | @impl EctoHooks 304 | def after_get(user, delta), do: user 305 | 306 | ### Benefits of v2.0 307 | 308 | - **Composability**: Add multiple middleware alongside `EctoHooks` 309 | - **Built on EctoMiddleware**: Leverage the full middleware ecosystem (included!) 310 | - **Flexibility**: Full control over middleware ordering 311 | - **Simplicity**: One less wrapper module to understand 312 | 313 | ### Example: Adding Other Middleware 314 | 315 | The new setup makes it easy to compose multiple middleware: 316 | 317 | def middleware(_action, _resource) do 318 | [ 319 | MyApp.RateLimiter, 320 | MyApp.Telemetry, 321 | EctoHooks, 322 | MyApp.CacheInvalidation 323 | ] 324 | end 325 | 326 | ## Implementation Details 327 | 328 | EctoHooks is implemented as an `EctoMiddleware` middleware that: 329 | - Implements `c:EctoMiddleware.process_before/2` for before hooks 330 | - Implements `c:EctoMiddleware.process_after/2` for after hooks 331 | - Uses `EctoMiddleware.Utils` guards for operation detection 332 | - Handles all Ecto return types (`{:ok, value}`, lists, tuples, etc.) 333 | """ 334 | 335 | use EctoMiddleware 336 | 337 | import EctoMiddleware.Utils, 338 | only: [is_insert: 2, is_update: 2, is_delete: 2, is_read: 2, is_preload: 2] 339 | 340 | alias EctoHooks.Delta 341 | alias EctoHooks.State 342 | 343 | @hooks [ 344 | :before_delete, 345 | :before_insert, 346 | :before_update, 347 | :after_delete, 348 | :after_get, 349 | :after_insert, 350 | :after_update 351 | ] 352 | 353 | # HACK: I don't want to add any dependencies I don't need to this library, so 354 | # we're just hackily generating a struct via this literal. Otherwise we'll 355 | # need to bring in Ecto as a dep, or ignore Dialyzer warnings. 356 | @callback before_insert(queryable :: %{__struct__: Ecto.Queryable}) :: %{ 357 | __struct__: Ecto.Queryable 358 | } 359 | @callback before_update(queryable :: %{__struct__: Ecto.Queryable}) :: %{ 360 | __struct__: Ecto.Queryable 361 | } 362 | @callback before_delete(queryable :: %{__struct__: Ecto.Queryable}) :: %{ 363 | __struct__: Ecto.Queryable 364 | } 365 | 366 | @callback after_get(schema_struct :: struct(), delta :: Delta.t()) :: struct() 367 | @callback after_insert(schema_struct :: struct(), delta :: Delta.t()) :: struct() 368 | @callback after_update(schema_struct :: struct(), delta :: Delta.t()) :: struct() 369 | @callback after_delete(schema_struct :: struct(), delta :: Delta.t()) :: struct() 370 | 371 | @optional_callbacks before_insert: 1, 372 | before_update: 1, 373 | before_delete: 1, 374 | after_get: 2, 375 | after_insert: 2, 376 | after_update: 2, 377 | after_delete: 2 378 | 379 | @doc false 380 | @impl EctoMiddleware 381 | def process_before(resource, resolution) when is_insert(resource, resolution.action) do 382 | {:cont, before_insert(resource, resolution.action)} 383 | end 384 | 385 | def process_before(resource, resolution) when is_update(resource, resolution.action) do 386 | {:cont, before_update(resource, resolution.action)} 387 | end 388 | 389 | def process_before(resource, resolution) when is_delete(resource, resolution.action) do 390 | {:cont, before_delete(resource, resolution.action)} 391 | end 392 | 393 | def process_before(resource, _resolution) do 394 | {:cont, resource} 395 | end 396 | 397 | @doc false 398 | @impl EctoMiddleware 399 | def process_after(result, resolution) when is_insert(resolution.entity, resolution.action) do 400 | {:cont, 401 | EctoMiddleware.Utils.apply(result, resolution, fn value -> 402 | after_insert(value, resolution.action, resolution.before_output) 403 | end)} 404 | end 405 | 406 | def process_after(result, resolution) when is_update(resolution.entity, resolution.action) do 407 | {:cont, 408 | EctoMiddleware.Utils.apply(result, resolution, fn value -> 409 | after_update(value, resolution.action, resolution.before_output) 410 | end)} 411 | end 412 | 413 | def process_after(result, resolution) when is_delete(resolution.entity, resolution.action) do 414 | {:cont, 415 | EctoMiddleware.Utils.apply(result, resolution, fn value -> 416 | after_delete(value, resolution.action, resolution.before_output) 417 | end)} 418 | end 419 | 420 | def process_after(resource, resolution) when is_preload(resource, resolution.action) do 421 | {:cont, 422 | case Enum.at(resolution.args, 1) do 423 | preloads when is_list(preloads) -> handle_preloads(resource, preloads) 424 | preload when is_atom(preload) -> handle_preloads(resource, [preload]) 425 | _otherwise -> resource 426 | end} 427 | end 428 | 429 | def process_after(result, resolution) when is_read(result, resolution.action) do 430 | {:cont, 431 | EctoMiddleware.Utils.apply(result, resolution, fn value -> 432 | after_get(value, resolution.action, resolution.before_output) 433 | end)} 434 | end 435 | 436 | def process_after(result, _resolution) do 437 | {:cont, result} 438 | end 439 | 440 | @doc """ 441 | Enables hooks for all future Repo operations in the current process. 442 | 443 | By default, hooks are enabled. You only need to call this if you've previously 444 | disabled hooks and want to re-enable them. 445 | 446 | ## Options 447 | 448 | - `:global` - When `true` (default), enables hooks globally for the process. 449 | When `false`, only enables hooks for the next operation (used internally). 450 | 451 | ## Examples 452 | 453 | # Disable hooks temporarily 454 | EctoHooks.disable_hooks() 455 | Repo.insert!(user) # Won't trigger hooks 456 | 457 | # Re-enable hooks 458 | EctoHooks.enable_hooks() 459 | Repo.insert!(user) # Will trigger hooks 460 | 461 | ## Internal Use 462 | 463 | EctoHooks uses `enable_hooks(global: false)` internally to re-enable hooks 464 | after executing a hook. This prevents infinite loops when hooks call Repo operations. 465 | """ 466 | @spec enable_hooks(Keyword.t()) :: :ok 467 | defdelegate enable_hooks(opts \\ [global: true]), to: State 468 | 469 | @doc """ 470 | Disables hooks for all future Repo operations in the current process. 471 | 472 | Useful when you need to perform Repo operations without triggering hooks, 473 | such as during bulk operations or setup/teardown in tests. 474 | 475 | ## Options 476 | 477 | - `:global` - When `true` (default), disables hooks globally for the process. 478 | When `false`, only disables hooks for the next operation (used internally). 479 | 480 | ## Examples 481 | 482 | # Disable hooks for bulk operations 483 | EctoHooks.disable_hooks() 484 | 485 | users 486 | |> Enum.each(&Repo.insert!/1) # None will trigger hooks 487 | 488 | # Re-enable when done 489 | EctoHooks.enable_hooks() 490 | 491 | # Or use in a specific scope 492 | try do 493 | EctoHooks.disable_hooks() 494 | perform_bulk_operation() 495 | after 496 | EctoHooks.enable_hooks() 497 | end 498 | 499 | ## Internal Use 500 | 501 | EctoHooks uses `disable_hooks(global: false)` internally to prevent infinite 502 | loops when a hook calls another Repo operation. 503 | """ 504 | @spec disable_hooks(Keyword.t()) :: :ok 505 | defdelegate disable_hooks(opts \\ [global: true]), to: State 506 | 507 | @doc """ 508 | Returns `true` if hooks are enabled for the current process, `false` otherwise. 509 | 510 | Use this to check whether hooks will execute before performing operations. 511 | 512 | ## Examples 513 | 514 | EctoHooks.hooks_enabled?() 515 | #=> true 516 | 517 | EctoHooks.disable_hooks() 518 | EctoHooks.hooks_enabled?() 519 | #=> false 520 | 521 | EctoHooks.enable_hooks() 522 | EctoHooks.hooks_enabled?() 523 | #=> true 524 | 525 | ## Notes 526 | 527 | This reflects the *global* state. Even if `hooks_enabled?/0` returns `true`, 528 | hooks won't execute if you're already inside a hook (see `in_hook?/0`). 529 | """ 530 | @spec hooks_enabled?() :: boolean() 531 | defdelegate hooks_enabled?, to: State 532 | 533 | @doc """ 534 | Returns `true` if currently executing inside a hook, `false` otherwise. 535 | 536 | EctoHooks automatically disables hooks when executing a hook to prevent 537 | infinite loops. This function lets you check if you're in that context. 538 | 539 | ## Examples 540 | 541 | # In your application code 542 | EctoHooks.in_hook?() 543 | #=> false 544 | 545 | # Inside a hook that calls Repo 546 | @impl EctoHooks 547 | def after_insert(user, _delta) do 548 | EctoHooks.in_hook?() 549 | #=> true 550 | 551 | # This won't trigger hooks 552 | Repo.update!(User.changeset(user, %{last_login: DateTime.utc_now()})) 553 | user 554 | end 555 | 556 | ## Implementation 557 | 558 | Internally, this checks if the hook ref count (see `hooks_ref_count/0`) is 559 | greater than zero. 560 | """ 561 | @spec in_hook?() :: boolean() 562 | defdelegate in_hook?, to: State 563 | 564 | @doc """ 565 | Returns the current hook nesting level (ref count) for the process. 566 | 567 | Each time a hook executes, the ref count increments. When the hook finishes, 568 | it decrements. This allows for nested hook detection, though in practice 569 | the nesting level is typically 0 or 1. 570 | 571 | ## Examples 572 | 573 | EctoHooks.hooks_ref_count() 574 | #=> 0 575 | 576 | # Inside a hook 577 | def after_insert(user, _delta) do 578 | EctoHooks.hooks_ref_count() 579 | #=> 1 580 | user 581 | end 582 | 583 | ## Use Cases 584 | 585 | This is a low-level introspection function. Most users should use 586 | `hooks_enabled?/0` or `in_hook?/0` instead. 587 | """ 588 | @spec hooks_ref_count() :: non_neg_integer() 589 | defdelegate hooks_ref_count, to: State 590 | 591 | # ============================================ 592 | # Hook Execution 593 | # ============================================ 594 | 595 | for hook <- @hooks do 596 | @doc false 597 | def unquote(hook)(struct, caller_function, delta \\ nil) 598 | 599 | def unquote(hook)(struct, caller_function, delta) when is_struct(struct) do 600 | hook = unquote(hook) 601 | 602 | struct 603 | |> get_schema_module() 604 | |> execute_hook(hook, struct, delta && Delta.new!(caller_function, hook, delta)) 605 | end 606 | 607 | def unquote(hook)(data, _delta, _caller_function) do 608 | data 609 | end 610 | end 611 | 612 | defp get_schema_module(struct) when is_struct(struct) do 613 | if struct.__struct__ == Ecto.Changeset && struct.data do 614 | struct.data.__struct__ 615 | else 616 | struct.__struct__ 617 | end 618 | end 619 | 620 | defp execute_hook(schema, hook, param_1, param_2) do 621 | if State.hooks_enabled?() do 622 | :ok = State.disable_hooks(global: false) 623 | :ok = State.acquire_hook() 624 | 625 | apply(schema, hook, [param_1 | (param_2 && [param_2]) || []]) 626 | else 627 | param_1 628 | end 629 | rescue 630 | e in [UndefinedFunctionError, FunctionClauseError] -> 631 | # Only catch if the error is for one of our hook callbacks 632 | if e.function in @hooks do 633 | param_1 634 | else 635 | reraise e, __STACKTRACE__ 636 | end 637 | after 638 | :ok = State.enable_hooks(global: false) 639 | :ok = State.release_hook() 640 | end 641 | 642 | # ============================================ 643 | # Preload Handling 644 | # ============================================ 645 | 646 | defp handle_preloads(structs, preloads) when is_list(structs) do 647 | Enum.map(structs, &handle_preloads(&1, preloads)) 648 | end 649 | 650 | defp handle_preloads(struct, preloads) do 651 | struct 652 | |> traverse_preloads(preloads) 653 | |> after_get(:preload, struct) 654 | end 655 | 656 | defp traverse_preloads(nil, _preloads), do: nil 657 | 658 | defp traverse_preloads(struct, preloads) when is_list(preloads) do 659 | Enum.reduce(preloads, struct, fn preload, acc -> traverse_preloads(acc, preload) end) 660 | end 661 | 662 | defp traverse_preloads(struct, {preload, nested_preloads}) do 663 | {_, updated_struct} = 664 | Map.get_and_update(struct, preload, fn v -> 665 | {v, handle_preloads(v, nested_preloads)} 666 | end) 667 | 668 | updated_struct 669 | end 670 | 671 | defp traverse_preloads(struct, preload) when is_atom(preload) do 672 | {_, updated_struct} = 673 | Map.get_and_update(struct, preload, fn v -> 674 | {v, handle_preloads(v, [])} 675 | end) 676 | 677 | updated_struct 678 | end 679 | 680 | defp traverse_preloads(queryable, _preload) do 681 | queryable 682 | end 683 | end 684 | -------------------------------------------------------------------------------- /test/ecto_hooks/repo_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoHooks.RepoTest do 2 | use ExUnit.Case, async: false 3 | 4 | alias EctoHooks.Delta 5 | 6 | def clear_hooks do 7 | hooks = [ 8 | :before_insert, 9 | :before_update, 10 | :before_delete, 11 | :after_get, 12 | :after_insert, 13 | :after_update, 14 | :after_delete 15 | ] 16 | 17 | for hook <- hooks, do: put_hook(hook, nil) 18 | :ok 19 | end 20 | 21 | def put_hook(hook_name, function) do 22 | Process.put({__MODULE__, hook_name}, function) 23 | :ok 24 | end 25 | 26 | def get_hook(hook_name) do 27 | case Atom.to_string(hook_name) do 28 | "after_" <> _rest -> 29 | Process.get({__MODULE__, hook_name}) || fn result, _delta -> result end 30 | 31 | _otherwise -> 32 | Process.get({__MODULE__, hook_name}) || fn changeset -> changeset end 33 | end 34 | end 35 | 36 | def bang?(repo_callback) do 37 | repo_callback 38 | |> Atom.to_string() 39 | |> String.ends_with?("!") 40 | end 41 | 42 | def unwrap({_term, x}), do: x 43 | def unwrap([x | _xs]), do: x 44 | def unwrap([]), do: nil 45 | def unwrap(x), do: x 46 | 47 | defmodule Repo do 48 | use Ecto.Repo, 49 | otp_app: :ecto_hooks, 50 | adapter: Etso.Adapter 51 | 52 | use EctoMiddleware.Repo 53 | 54 | @impl EctoMiddleware.Repo 55 | def middleware(_action, _resource) do 56 | [EctoHooks] 57 | end 58 | end 59 | 60 | defmodule UserTeam do 61 | use Ecto.Schema 62 | import Ecto.Changeset 63 | import Ecto.Query, warn: false 64 | 65 | schema "user_team" do 66 | belongs_to(:user, EctoHooks.RepoTest.User) 67 | belongs_to(:team, EctoHooks.RepoTest.Team) 68 | end 69 | 70 | def changeset(%__MODULE__{} = user_team, attrs) do 71 | user_team 72 | |> cast(attrs, [:user_id, :team_id]) 73 | |> validate_required([:user_id, :team_id]) 74 | end 75 | end 76 | 77 | defmodule Team do 78 | use Ecto.Schema 79 | import Ecto.Changeset 80 | import Ecto.Query, warn: false 81 | 82 | schema "team" do 83 | field(:name, :string) 84 | belongs_to(:owner, EctoHooks.RepoTest.User) 85 | has_many(:users_teams, EctoHooks.RepoTest.UserTeam) 86 | has_many(:users, through: [:users_teams, :user]) 87 | end 88 | 89 | def changeset(%__MODULE__{} = team, attrs) do 90 | team 91 | |> cast(attrs, [:name, :owner_id]) 92 | |> validate_required([:name, :owner_id]) 93 | end 94 | end 95 | 96 | defmodule User do 97 | use Ecto.Schema 98 | import Ecto.Changeset 99 | import Ecto.Query, warn: false 100 | 101 | schema "user" do 102 | field(:first_name, :string) 103 | field(:last_name, :string) 104 | 105 | field(:full_name, :string, virtual: true) 106 | has_many(:users_teams, EctoHooks.RepoTest.Team) 107 | has_many(:teams, through: [:users_teams, :team]) 108 | end 109 | 110 | def before_insert(%Ecto.Changeset{} = changeset), 111 | do: EctoHooks.RepoTest.get_hook(:before_insert).(changeset) 112 | 113 | def before_update(%Ecto.Changeset{} = changeset), 114 | do: EctoHooks.RepoTest.get_hook(:before_update).(changeset) 115 | 116 | def before_delete(%__MODULE__{} = schema), 117 | do: EctoHooks.RepoTest.get_hook(:before_delete).(schema) 118 | 119 | def after_get(%__MODULE__{} = schema, %Delta{} = delta), 120 | do: EctoHooks.RepoTest.get_hook(:after_get).(schema, delta) 121 | 122 | def after_insert(%__MODULE__{} = schema, %Delta{} = delta), 123 | do: EctoHooks.RepoTest.get_hook(:after_insert).(schema, delta) 124 | 125 | def after_update(%__MODULE__{} = schema, %Delta{} = delta), 126 | do: EctoHooks.RepoTest.get_hook(:after_update).(schema, delta) 127 | 128 | def after_delete(%__MODULE__{} = schema, %Delta{} = delta), 129 | do: EctoHooks.RepoTest.get_hook(:after_delete).(schema, delta) 130 | 131 | def changeset(%__MODULE__{} = user, attrs) do 132 | user 133 | |> cast(attrs, [:first_name, :last_name]) 134 | |> validate_required([:first_name, :last_name]) 135 | end 136 | end 137 | 138 | setup do 139 | {:ok, _repo} = start_supervised(%{id: __MODULE__, start: {Repo, :start_link, []}}) 140 | :ok = clear_hooks() 141 | end 142 | 143 | describe "before_insert/2" do 144 | for repo_callback <- [:insert, :insert_or_update, :insert!, :insert_or_update!] do 145 | good_test_name = "executes before successful Repo.#{repo_callback}/1" 146 | bad_test_name = "executes before unsuccessful Repo.#{repo_callback}/1" 147 | 148 | test good_test_name do 149 | put_hook(:before_insert, fn %Ecto.Changeset{} = changeset -> 150 | Ecto.Changeset.force_change(changeset, :last_name, "Marley") 151 | end) 152 | 153 | user = 154 | %User{} 155 | |> User.changeset(%{first_name: "Bob", last_name: "Dylan"}) 156 | |> Repo.unquote(repo_callback) 157 | 158 | assert %User{first_name: "Bob", last_name: "Marley"} = unwrap(user) 159 | end 160 | 161 | test bad_test_name do 162 | expected_message = Ecto.UUID.generate() 163 | 164 | put_hook(:before_insert, fn %Ecto.Changeset{} -> 165 | send(self(), expected_message) 166 | end) 167 | 168 | try do 169 | %User{} 170 | |> User.changeset(%{}) 171 | |> Repo.unquote(repo_callback) 172 | rescue 173 | _e -> 174 | :noop 175 | after 176 | assert_received(^expected_message) 177 | end 178 | end 179 | end 180 | end 181 | 182 | describe "before_update/2" do 183 | setup do 184 | user = 185 | %User{} 186 | |> User.changeset(%{first_name: "Bob", last_name: "Dylan"}) 187 | |> Repo.insert!() 188 | 189 | {:ok, user: user} 190 | end 191 | 192 | for repo_callback <- [:update, :insert_or_update, :update!, :insert_or_update!] do 193 | good_test_name = "executes before successful Repo.#{repo_callback}/1" 194 | bad_test_name = "executes before unsuccessful Repo.#{repo_callback}/1" 195 | 196 | test good_test_name, ctx do 197 | put_hook(:before_update, fn %Ecto.Changeset{} = changeset -> 198 | Ecto.Changeset.force_change(changeset, :last_name, "Eager") 199 | end) 200 | 201 | user = 202 | ctx.user 203 | |> User.changeset(%{first_name: "Bob", last_name: "Dylan"}) 204 | |> Repo.unquote(repo_callback) 205 | 206 | assert %User{first_name: "Bob", last_name: "Eager"} = unwrap(user) 207 | end 208 | 209 | test bad_test_name, ctx do 210 | expected_message = Ecto.UUID.generate() 211 | 212 | put_hook(:before_update, fn %Ecto.Changeset{} -> 213 | send(self(), expected_message) 214 | end) 215 | 216 | try do 217 | ctx.user 218 | |> User.changeset(%{}) 219 | |> Repo.unquote(repo_callback) 220 | rescue 221 | _e -> 222 | :noop 223 | after 224 | assert_received(^expected_message) 225 | end 226 | end 227 | end 228 | end 229 | 230 | describe "before_delete/2" do 231 | setup do 232 | user = 233 | %User{} 234 | |> User.changeset(%{first_name: "Bob", last_name: "Dylan"}) 235 | |> Repo.insert!() 236 | 237 | {:ok, user: user} 238 | end 239 | 240 | for repo_callback <- [:delete, :delete!] do 241 | good_test_name = "executes before successful Repo.#{repo_callback}/1" 242 | bad_test_name = "executes before unsuccessful Repo.#{repo_callback}/1" 243 | 244 | test good_test_name, ctx do 245 | expected_message = Ecto.UUID.generate() 246 | 247 | put_hook(:before_delete, fn schema -> 248 | send(self(), expected_message) 249 | schema 250 | end) 251 | 252 | assert %User{} = 253 | ctx.user 254 | |> Repo.unquote(repo_callback) 255 | |> unwrap() 256 | 257 | assert_received(^expected_message) 258 | end 259 | 260 | test bad_test_name do 261 | expected_message = Ecto.UUID.generate() 262 | 263 | put_hook(:before_delete, fn schema -> 264 | send(self(), expected_message) 265 | schema 266 | end) 267 | 268 | assert_raise Ecto.NoPrimaryKeyValueError, fn -> 269 | %User{} 270 | |> Repo.unquote(repo_callback) 271 | |> unwrap() 272 | end 273 | 274 | assert_received(^expected_message) 275 | end 276 | end 277 | end 278 | 279 | describe "after_get/2" do 280 | setup do 281 | user = 282 | %User{} 283 | |> User.changeset(%{first_name: "Bob", last_name: "Dylan"}) 284 | |> Repo.insert!() 285 | 286 | {:ok, user: user} 287 | end 288 | 289 | for repo_callback <- [:reload, :reload!] do 290 | singular_test_name = "executes after successful Repo.#{repo_callback}/1 given struct" 291 | plural_test_name = "executes after successful Repo.#{repo_callback}/1 given list" 292 | 293 | test singular_test_name, ctx do 294 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 295 | assert delta.hook == :after_get 296 | assert delta.repo_callback == unquote(repo_callback) 297 | assert delta.source == delta.record 298 | %{user | full_name: user.first_name <> " " <> user.last_name} 299 | end) 300 | 301 | response = Repo.unquote(repo_callback)(ctx.user) 302 | assert %User{full_name: "Bob Dylan"} = unwrap(response) 303 | end 304 | 305 | test plural_test_name, ctx do 306 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 307 | assert delta.hook == :after_get 308 | assert delta.repo_callback == unquote(repo_callback) 309 | assert delta.source == delta.record 310 | 311 | %{user | full_name: user.first_name <> " " <> user.last_name} 312 | end) 313 | 314 | [response] = Repo.unquote(repo_callback)([ctx.user]) 315 | assert %User{full_name: "Bob Dylan"} = unwrap(response) 316 | end 317 | end 318 | 319 | for repo_callback <- [:all, :one, :one!] do 320 | test_name = "executes after successful Repo.#{repo_callback}/1" 321 | 322 | test test_name do 323 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 324 | assert delta.hook == :after_get 325 | assert delta.repo_callback == unquote(repo_callback) 326 | assert delta.source == delta.queryable 327 | 328 | %{user | full_name: user.first_name <> " " <> user.last_name} 329 | end) 330 | 331 | response = Repo.unquote(repo_callback)(User) 332 | 333 | assert %User{full_name: "Bob Dylan"} = unwrap(response) 334 | end 335 | end 336 | 337 | for repo_callback <- [:get, :get!] do 338 | good_test_name = "executes after successful Repo.#{repo_callback}/1" 339 | bad_test_name = "does not execute after unsuccessful Repo.#{repo_callback}/1" 340 | 341 | test good_test_name, ctx do 342 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 343 | assert delta.hook == :after_get 344 | assert delta.repo_callback == unquote(repo_callback) 345 | assert delta.source == delta.queryable 346 | 347 | %{user | full_name: user.first_name <> " " <> user.last_name} 348 | end) 349 | 350 | response = Repo.unquote(repo_callback)(User, ctx.user.id) 351 | 352 | assert %User{full_name: "Bob Dylan"} = unwrap(response) 353 | end 354 | 355 | test bad_test_name do 356 | put_hook(:after_get, fn %User{}, %Delta{} -> 357 | flunk("This hook should not have been called!") 358 | end) 359 | 360 | assert is_nil(Repo.unquote(repo_callback)(User, 1234)) 361 | rescue 362 | e in Ecto.NoResultsError -> 363 | unless bang?(unquote(repo_callback)), do: reraise(e, __STACKTRACE__) 364 | end 365 | end 366 | 367 | for repo_callback <- [:get_by, :get_by!] do 368 | good_test_name = "executes after successful Repo.#{repo_callback}/1" 369 | bad_test_name = "does not execute after unsuccessful Repo.#{repo_callback}/1" 370 | 371 | test good_test_name, ctx do 372 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 373 | assert delta.hook == :after_get 374 | assert delta.repo_callback == unquote(repo_callback) 375 | assert delta.source == delta.queryable 376 | 377 | %{user | full_name: user.first_name <> " " <> user.last_name} 378 | end) 379 | 380 | response = Repo.unquote(repo_callback)(User, id: ctx.user.id) 381 | 382 | assert %User{full_name: "Bob Dylan"} = unwrap(response) 383 | end 384 | 385 | test bad_test_name do 386 | put_hook(:after_get, fn %User{}, %Delta{} -> 387 | flunk("This hook should not have been called!") 388 | end) 389 | 390 | assert is_nil(Repo.unquote(repo_callback)(User, id: 1234)) 391 | rescue 392 | e in Ecto.NoResultsError -> 393 | unless bang?(unquote(repo_callback)), do: reraise(e, __STACKTRACE__) 394 | end 395 | end 396 | end 397 | 398 | describe "after_get/2 preload cases" do 399 | setup do 400 | user = 401 | %User{} 402 | |> User.changeset(%{first_name: "Bob", last_name: "Dylan"}) 403 | |> Repo.insert!() 404 | 405 | team = 406 | %Team{} 407 | |> Team.changeset(%{name: "#{user.first_name}'s team", owner_id: user.id}) 408 | |> Repo.insert!() 409 | 410 | _user_team = 411 | %UserTeam{} 412 | |> UserTeam.changeset(%{user_id: user.id, team_id: team.id}) 413 | |> Repo.insert!() 414 | 415 | {:ok, user: user, team: team} 416 | end 417 | 418 | repo_callback = :preload 419 | singular_test_name = "executes after successful Repo.#{repo_callback}/3 on struct with" 420 | plural_test_name = "executes after successful Repo.#{repo_callback}/3 on list with" 421 | 422 | test "#{singular_test_name} nil" do 423 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 424 | assert delta.hook == :after_get 425 | assert delta.repo_callback == unquote(repo_callback) 426 | assert delta.source == delta.record 427 | 428 | %{user | full_name: user.first_name <> " " <> user.last_name} 429 | end) 430 | 431 | response = Repo.unquote(repo_callback)(nil, :owner) 432 | assert is_nil(unwrap(response)) 433 | end 434 | 435 | test "#{singular_test_name} single preload", ctx do 436 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 437 | assert delta.hook == :after_get 438 | assert delta.repo_callback == unquote(repo_callback) 439 | assert delta.source == delta.record 440 | %{user | full_name: user.first_name <> " " <> user.last_name} 441 | end) 442 | 443 | response = Repo.unquote(repo_callback)(ctx.team, :owner) 444 | assert %User{full_name: "Bob Dylan"} = unwrap(response).owner 445 | end 446 | 447 | test "#{plural_test_name} single preload", ctx do 448 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 449 | assert delta.hook == :after_get 450 | assert delta.repo_callback == unquote(repo_callback) 451 | assert delta.source == delta.record 452 | 453 | %{user | full_name: user.first_name <> " " <> user.last_name} 454 | end) 455 | 456 | [response] = Repo.unquote(repo_callback)([ctx.team], [:owner]) 457 | assert %User{full_name: "Bob Dylan"} = unwrap(response).owner 458 | end 459 | 460 | test "#{singular_test_name} multiple singular preloads", ctx do 461 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 462 | assert delta.hook == :after_get 463 | assert delta.repo_callback == unquote(repo_callback) 464 | assert delta.source == delta.record 465 | 466 | %{user | full_name: user.first_name <> " " <> user.last_name} 467 | end) 468 | 469 | response = Repo.unquote(repo_callback)(ctx.team, [:owner, :users]) 470 | assert %User{full_name: "Bob Dylan"} = unwrap(response).owner 471 | 472 | assert Enum.all?(unwrap(response).users, fn user -> 473 | %User{full_name: "Bob Dylan"} = user 474 | end) 475 | end 476 | 477 | test "#{plural_test_name} multiple singular preloads", ctx do 478 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 479 | assert delta.hook == :after_get 480 | assert delta.repo_callback == unquote(repo_callback) 481 | assert delta.source == delta.record 482 | 483 | %{user | full_name: user.first_name <> " " <> user.last_name} 484 | end) 485 | 486 | [response] = Repo.unquote(repo_callback)([ctx.team], [:owner, :users]) 487 | assert %User{full_name: "Bob Dylan"} = unwrap(response).owner 488 | 489 | assert Enum.all?(unwrap(response).users, fn user -> 490 | %User{full_name: "Bob Dylan"} = user 491 | end) 492 | end 493 | 494 | test "#{singular_test_name} mixed atom and query preloads", ctx do 495 | require Ecto.Query 496 | 497 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 498 | assert delta.hook == :after_get 499 | assert delta.repo_callback == unquote(repo_callback) 500 | assert delta.source == delta.record 501 | 502 | %{user | full_name: user.first_name <> " " <> user.last_name} 503 | end) 504 | 505 | response = 506 | Repo.unquote(repo_callback)( 507 | ctx.team, 508 | [ 509 | :users, 510 | owner: Ecto.Query.from(u in EctoHooks.RepoTest.User), 511 | users_teams: Ecto.Query.from(ut in EctoHooks.RepoTest.UserTeam) 512 | ] 513 | ) 514 | 515 | assert %User{full_name: "Bob Dylan"} = unwrap(response).owner 516 | 517 | # when relation is explicitly preloaded the hook is reached 518 | assert Enum.all?(unwrap(response).users, fn user -> %User{full_name: "Bob Dylan"} = user end) 519 | 520 | # when relation is NOT explicitly preloaded the hook is NOT reached 521 | assert Enum.all?(unwrap(response).users_teams, fn ut -> 522 | %User{full_name: nil} = ut.user 523 | end) 524 | end 525 | 526 | test "#{plural_test_name} mixed atom and query preloads", ctx do 527 | require Ecto.Query 528 | 529 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 530 | assert delta.hook == :after_get 531 | assert delta.repo_callback == unquote(repo_callback) 532 | assert delta.source == delta.record 533 | 534 | %{user | full_name: user.first_name <> " " <> user.last_name} 535 | end) 536 | 537 | [response] = 538 | Repo.unquote(repo_callback)( 539 | [ctx.team], 540 | [ 541 | :users, 542 | owner: Ecto.Query.from(u in EctoHooks.RepoTest.User), 543 | users_teams: Ecto.Query.from(ut in EctoHooks.RepoTest.UserTeam) 544 | ] 545 | ) 546 | 547 | assert %User{full_name: "Bob Dylan"} = unwrap(response).owner 548 | 549 | # when relation is explicitly preloaded the hook is reached 550 | assert Enum.all?(unwrap(response).users, fn user -> %User{full_name: "Bob Dylan"} = user end) 551 | 552 | # when relation is NOT explicitly preloaded the hook is NOT reached 553 | assert Enum.all?(unwrap(response).users_teams, fn ut -> 554 | %User{full_name: nil} = ut.user 555 | end) 556 | end 557 | 558 | test "#{singular_test_name} explicit nested preloads", ctx do 559 | require Ecto.Query 560 | 561 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 562 | assert delta.hook == :after_get 563 | assert delta.repo_callback == unquote(repo_callback) 564 | assert delta.source == delta.record 565 | 566 | %{user | full_name: user.first_name <> " " <> user.last_name} 567 | end) 568 | 569 | response = 570 | Repo.unquote(repo_callback)( 571 | ctx.team, 572 | users_teams: [:user, team: [:owner]] 573 | ) 574 | 575 | assert Enum.all?(unwrap(response).users_teams, fn ut -> 576 | %User{full_name: "Bob Dylan"} = ut.user 577 | end) 578 | 579 | assert Enum.all?(unwrap(response).users_teams, fn ut -> 580 | %User{full_name: "Bob Dylan"} = ut.team.owner 581 | end) 582 | end 583 | 584 | test "#{plural_test_name} explicit nested preloads", ctx do 585 | require Ecto.Query 586 | 587 | put_hook(:after_get, fn %User{full_name: nil} = user, %Delta{} = delta -> 588 | assert delta.hook == :after_get 589 | assert delta.repo_callback == unquote(repo_callback) 590 | assert delta.source == delta.record 591 | 592 | %{user | full_name: user.first_name <> " " <> user.last_name} 593 | end) 594 | 595 | [response] = 596 | Repo.unquote(repo_callback)( 597 | [ctx.team], 598 | users_teams: [:user, team: [:owner]] 599 | ) 600 | 601 | assert Enum.all?(unwrap(response).users_teams, fn ut -> 602 | %User{full_name: "Bob Dylan"} = ut.user 603 | end) 604 | 605 | assert Enum.all?(unwrap(response).users_teams, fn ut -> 606 | %User{full_name: "Bob Dylan"} = ut.team.owner 607 | end) 608 | end 609 | end 610 | 611 | describe "after_insert/2" do 612 | for repo_callback <- [:insert, :insert_or_update, :insert!, :insert_or_update!] do 613 | good_test_name = "executes after successful Repo.#{repo_callback}/1" 614 | bad_test_name = "does not execute after unsuccessful Repo.#{repo_callback}/1" 615 | 616 | test good_test_name do 617 | put_hook(:after_insert, fn %User{full_name: nil} = user, %Delta{} = delta -> 618 | assert delta.hook == :after_insert 619 | assert delta.repo_callback == unquote(repo_callback) 620 | assert delta.source == delta.changeset 621 | assert %Ecto.Changeset{valid?: true} = delta.changeset 622 | 623 | %{user | full_name: user.first_name <> " " <> user.last_name} 624 | end) 625 | 626 | response = 627 | %User{} 628 | |> User.changeset(%{first_name: "Bob", last_name: "Dylan"}) 629 | |> Repo.unquote(repo_callback) 630 | 631 | assert %User{full_name: "Bob Dylan"} = unwrap(response) 632 | end 633 | 634 | test bad_test_name do 635 | put_hook(:after_insert, fn %User{full_name: nil}, %Delta{} -> 636 | flunk("This hook should not have been called!") 637 | end) 638 | 639 | response = 640 | %User{} 641 | |> User.changeset(%{}) 642 | |> Repo.unquote(repo_callback) 643 | 644 | assert %Ecto.Changeset{errors: _errors} = unwrap(response) 645 | rescue 646 | e in Ecto.InvalidChangesetError -> 647 | unless bang?(unquote(repo_callback)), do: reraise(e, __STACKTRACE__) 648 | end 649 | end 650 | end 651 | 652 | describe "after_update/2" do 653 | setup do 654 | user = 655 | %User{} 656 | |> User.changeset(%{first_name: "Bob", last_name: "Dylan"}) 657 | |> Repo.insert!() 658 | 659 | {:ok, user: user} 660 | end 661 | 662 | for repo_callback <- [:update, :insert_or_update, :update!, :insert_or_update!] do 663 | good_test_name = "executes after successful Repo.#{repo_callback}/1" 664 | bad_test_name = "does not execute after unsuccessful Repo.#{repo_callback}/1" 665 | 666 | test good_test_name, %{user: seeded_user} do 667 | put_hook(:after_update, fn user, %Delta{} = delta -> 668 | assert seeded_user.id == user.id 669 | assert delta.hook == :after_update 670 | assert delta.repo_callback == unquote(repo_callback) 671 | assert delta.source == delta.changeset 672 | assert %Ecto.Changeset{valid?: true} = delta.changeset 673 | 674 | %{user | full_name: user.first_name <> " " <> user.last_name} 675 | end) 676 | 677 | response = 678 | seeded_user 679 | |> User.changeset(%{last_name: "Marley"}) 680 | |> Repo.unquote(repo_callback) 681 | 682 | assert %User{full_name: "Bob Marley"} = unwrap(response) 683 | end 684 | 685 | test bad_test_name do 686 | put_hook(:after_update, fn %User{full_name: nil}, %Delta{} -> 687 | flunk("This hook should not have been called!") 688 | end) 689 | 690 | response = 691 | %User{} 692 | |> User.changeset(%{}) 693 | |> Repo.unquote(repo_callback) 694 | 695 | assert %Ecto.Changeset{errors: _errors} = unwrap(response) 696 | rescue 697 | e in Ecto.InvalidChangesetError -> 698 | unless bang?(unquote(repo_callback)), do: reraise(e, __STACKTRACE__) 699 | end 700 | end 701 | end 702 | 703 | describe "after_delete/2" do 704 | setup do 705 | user = 706 | %User{} 707 | |> User.changeset(%{first_name: "Bob", last_name: "Dylan"}) 708 | |> Repo.insert!() 709 | 710 | {:ok, user: user} 711 | end 712 | 713 | for repo_callback <- [:delete, :delete!] do 714 | good_test_name = "executes after successful Repo.#{repo_callback}/1" 715 | bad_test_name = "does not execute after unsuccessful Repo.#{repo_callback}/1" 716 | 717 | test good_test_name, %{user: seeded_user} do 718 | put_hook(:after_delete, fn user, %Delta{} = delta -> 719 | assert seeded_user.id == user.id 720 | assert delta.hook == :after_delete 721 | assert delta.repo_callback == unquote(repo_callback) 722 | assert delta.source == delta.record 723 | 724 | %{user | full_name: user.first_name <> " " <> user.last_name} 725 | end) 726 | 727 | # Deleting records still returns them, plus any changes a hook might 728 | # have made, so assert the database as well. 729 | assert [^seeded_user] = Repo.all(User) 730 | 731 | response = 732 | seeded_user 733 | |> Repo.unquote(repo_callback) 734 | 735 | assert %User{full_name: "Bob Dylan"} = unwrap(response) 736 | assert [] = Repo.all(User) 737 | end 738 | 739 | test bad_test_name do 740 | put_hook(:after_update, fn %User{full_name: nil}, %Delta{} -> 741 | flunk("This hook should not have been called!") 742 | end) 743 | 744 | assert_raise Ecto.NoPrimaryKeyValueError, fn -> 745 | Repo.unquote(repo_callback)(%User{}) 746 | end 747 | end 748 | end 749 | end 750 | end 751 | --------------------------------------------------------------------------------