├── 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 |
3 |
4 |
5 | # EctoHooks
6 |
7 | [](https://hex.pm/packages/ecto_hooks)
8 | [](https://hexdocs.pm/ecto_hooks/)
9 | [](https://github.com/vereis/ecto_hooks/actions)
10 | [](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 |
--------------------------------------------------------------------------------