├── .formatter.exs ├── .github └── workflows │ └── test.yml ├── .gitignore ├── README.md ├── config ├── config.exs ├── runtime.exs └── test.exs ├── lib ├── autocontext.ex ├── callbacks.ex └── finders.ex ├── mix.exs ├── mix.lock ├── priv └── repo │ └── migrations │ ├── 20230710132002_create_users.exs │ └── 20230710132127_create_accounts.exs └── test ├── autocontext_test.exs ├── callbacks_test.exs ├── support ├── callback_mock.ex ├── models.ex └── repo.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | 3 | # Define workflow that runs when changes are pushed to the 4 | # `main` branch or pushed to a PR branch that targets the `main` 5 | # branch. Change the branch name if your project uses a 6 | # different name for the main branch like "master" or "production". 7 | on: 8 | push: 9 | branches: [ "main" ] # adapt branch for project 10 | pull_request: 11 | branches: [ "main" ] # adapt branch for project 12 | 13 | # Sets the ENV `MIX_ENV` to `test` for running tests 14 | env: 15 | MIX_ENV: test 16 | POSTGRES_PASSWORD: "postgres" 17 | 18 | permissions: 19 | contents: read 20 | 21 | jobs: 22 | test: 23 | # Set up a Postgres DB service. By default, Phoenix applications 24 | # use Postgres. This creates a database for running tests. 25 | # Additional services can be defined here if required. 26 | services: 27 | db: 28 | image: postgres:12 29 | ports: ['5432:5432'] 30 | env: 31 | POSTGRES_PASSWORD: postgres 32 | options: >- 33 | --health-cmd pg_isready 34 | --health-interval 10s 35 | --health-timeout 5s 36 | --health-retries 5 37 | 38 | runs-on: ubuntu-latest 39 | name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 40 | strategy: 41 | # Specify the OTP and Elixir versions to use when building 42 | # and running the workflow steps. 43 | matrix: 44 | otp: ['25.0.4'] # Define the OTP version [required] 45 | elixir: ['1.14.1'] # Define the elixir version [required] 46 | steps: 47 | # Step: Setup Elixir + Erlang image as the base. 48 | - name: Set up Elixir 49 | uses: erlef/setup-beam@v1 50 | with: 51 | otp-version: ${{matrix.otp}} 52 | elixir-version: ${{matrix.elixir}} 53 | 54 | # Step: Check out the code. 55 | - name: Checkout code 56 | uses: actions/checkout@v3 57 | 58 | # Step: Define how to cache deps. Restores existing cache if present. 59 | - name: Cache deps 60 | id: cache-deps 61 | uses: actions/cache@v3 62 | env: 63 | cache-name: cache-elixir-deps 64 | with: 65 | path: deps 66 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 67 | restore-keys: | 68 | ${{ runner.os }}-mix-${{ env.cache-name }}- 69 | 70 | # Step: Define how to cache the `_build` directory. After the first run, 71 | # this speeds up tests runs a lot. This includes not re-compiling our 72 | # project's downloaded deps every run. 73 | - name: Cache compiled build 74 | id: cache-build 75 | uses: actions/cache@v3 76 | env: 77 | cache-name: cache-compiled-build 78 | with: 79 | path: _build 80 | key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} 81 | restore-keys: | 82 | ${{ runner.os }}-mix-${{ env.cache-name }}- 83 | ${{ runner.os }}-mix- 84 | 85 | # Step: Download project dependencies. If unchanged, uses 86 | # the cached version. 87 | - name: Install dependencies 88 | run: mix deps.get 89 | 90 | # Step: Compile the project treating any warnings as errors. 91 | # Customize this step if a different behavior is desired. 92 | - name: Compiles without warnings 93 | run: mix compile #--warnings-as-errors 94 | 95 | - name: DB Create 96 | run: mix ecto.create 97 | 98 | - name: DB Migrate 99 | run: mix ecto.migrate 100 | 101 | # Step: Check that the checked in code has already been formatted. 102 | # This step fails if something was found unformatted. 103 | # Customize this step as desired. 104 | #- name: Check Formatting 105 | # run: mix format --check-formatted 106 | 107 | # Step: Execute the tests. 108 | - name: Run tests 109 | run: mix test -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | autocontext-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | doc 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Elixir CI](https://github.com/michelson/autocontext/actions/workflows/test.yml/badge.svg)](https://github.com/michelson/autocontext/actions/workflows/test.yml) 2 | 3 | # Autocontext 4 | 5 | `Autocontext` is an Elixir library that provides ActiveRecord-like callbacks for Ecto, Elixir's database wrapper. This allows you to specify functions to be executed before and after certain operations (`insert`, `update`, `delete`), enhancing control over these operations and maintaining a clean and expressive code base. 6 | 7 | ## Features 8 | 9 | - `before_save`, `after_save`, `before_create`, `after_create`, `before_update`, `after_update`, `before_delete`, `after_delete` callbacks. 10 | - Fully customizable with the ability to specify your own callback functions. 11 | - Supports the Repo option, which allows you to use different Repo configurations. 12 | - Works seamlessly with Ecto's changesets and other features. 13 | - Multiple changesets and multiple schemas are allowed. 14 | 15 | --- 16 | 17 | ## Installation 18 | 19 | Add the `autocontext` to your list of dependencies in `mix.exs`: 20 | 21 | ```elixir 22 | def deps do 23 | [ 24 | {:autocontext, "~> 0.1.0"} 25 | ] 26 | end 27 | ``` 28 | 29 | ## Usage 30 | 31 | Define a context module for your Ecto operations and `use Autocontext.EctoCallbacks`: 32 | 33 | ```elixir 34 | defmodule MyApp.Accounts do 35 | use Autocontext.EctoCallbacks, operations: [ 36 | [ 37 | name: :user, 38 | repo: MyApp.Repo, 39 | schema: MyApp.User, 40 | changeset: &MyApp.User.changeset/2, 41 | use_transaction: true, 42 | before_save: [:validate_username, :hash_password], 43 | after_save: [:send_welcome_email, :track_user_creation] 44 | ], 45 | [ 46 | name: :admin, 47 | repo: MyApp.Repo, 48 | schema: MyApp.Admin, 49 | changeset: &MyApp.Admin.changeset/2, 50 | use_transaction: false, 51 | before_create: [:check_admin_limit], 52 | after_create: [:send_admin_email] 53 | ] 54 | ] 55 | 56 | # Callback implementations... 57 | end 58 | ``` 59 | 60 | In the above configuration: 61 | 62 | - `:name` defines a unique name for the operation. This name is used to generate the actual Ecto operation functions: `user_create`, `user_update`, `user_delete`, `admin_create`, `admin_update`, `admin_delete`. If the option is nil then a `create`, `delete`, and `update` methods will be created for the schema. 63 | - `:repo` is the Ecto repository to interact with. 64 | - `:schema` is the Ecto schema for the data structure. 65 | - `:changeset` is the changeset function used for the data validation and transformations. 66 | - `:use_transaction` is a boolean flag to indicate whether to perform the operations within a database transaction or not. 67 | - The remaining keys (`:before_save`, `:after_save`, `:before_create`, `:after_create`, etc.) define the callback functions to be called before or after the actual Ecto operations. These callbacks must be implemented within the same module and they should return the changeset or record they receive. 68 | 69 | Then, you can call the functions as follows: 70 | 71 | ```elixir 72 | params = %{name: "john_doe", email: "john_doe@example.com", age: 10} 73 | 74 | case MyApp.Accounts.user_create(params) do 75 | {:ok, user} -> 76 | IO.puts("User created successfully.") 77 | 78 | {:error, changeset} -> 79 | IO.puts("Failed to create user.") 80 | end 81 | ``` 82 | 83 | --- 84 | 85 | # Finders 86 | 87 | Finders gives an easy way to access records, like find, find_by and all 88 | 89 | ```elixir 90 | defmodule Autocontext.Accounts do 91 | use Autocontext.Finders, 92 | schema: User, 93 | repo: Repo 94 | end 95 | ``` 96 | 97 | ```elixir 98 | Accounts.find!(1) 99 | Accounts.find_by!(name: "mike") 100 | Accounts.all 101 | ``` 102 | 103 | ## Installation 104 | 105 | Add `autocontext` to your list of dependencies in `mix.exs`: 106 | 107 | ```elixir 108 | def deps do 109 | [ 110 | {:autocontext, "~> 0.1.0"} 111 | ] 112 | end 113 | ``` 114 | 115 | Then, update your dependencies: 116 | 117 | ``` 118 | $ mix deps.get 119 | ``` -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :autocontext, ecto_repos: [Autocontext.Repo] 4 | 5 | config :autocontext, repo: Autocontext.Repo 6 | 7 | # import_config "#{Mix.env()}.exs" 8 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :autocontext, Autocontext.Repo, 4 | adapter: Ecto.Adapters.Postgres, 5 | username: System.get_env("POSTGRES_USER") || "postgres", 6 | password: System.get_env("POSTGRES_PASSWORD") || "", 7 | database: "autocontext_test", 8 | hostname: "localhost", 9 | poolsize: 10, 10 | pool: Ecto.Adapters.SQL.Sandbox 11 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | config :autocontext, ecto_repos: [Autocontext.Repo] 2 | 3 | config :autocontext, repo: Autocontext.Repo 4 | 5 | config :autocontext, Autocontext.Repo, 6 | adapter: Ecto.Adapters.Postgres, 7 | username: "postgres", 8 | password: "postgres", 9 | database: "autocontext_test", 10 | hostname: "db", 11 | poolsize: 10 12 | -------------------------------------------------------------------------------- /lib/autocontext.ex: -------------------------------------------------------------------------------- 1 | defmodule Autocontext do 2 | @moduledoc """ 3 | Documentation for `Autocontext`. 4 | """ 5 | 6 | @doc """ 7 | Hello world. 8 | 9 | ## Examples 10 | 11 | iex> Autocontext.hello() 12 | :world 13 | 14 | """ 15 | def hello do 16 | :world 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/callbacks.ex: -------------------------------------------------------------------------------- 1 | defmodule Autocontext.EctoCallbacks do 2 | defmacro __using__(opts) do 3 | operations = Keyword.get(opts, :operations, []) 4 | 5 | for operation <- operations do 6 | operation_name = Keyword.get(operation, :name, nil) 7 | operation_name = if operation_name, do: "#{operation_name}_", else: "" 8 | 9 | quote do 10 | def unquote(:"#{operation_name}create")(params) do 11 | repo = unquote(operation[:repo]) 12 | schema = unquote(operation[:schema]) 13 | changeset_fun = unquote(operation[:changeset]) 14 | use_transaction = unquote(operation[:use_transaction]) 15 | before_create = unquote(operation[:before_create]) 16 | after_create = unquote(operation[:after_create]) 17 | before_save = unquote(operation[:before_save]) 18 | after_save = unquote(operation[:after_save]) 19 | 20 | changeset = changeset_fun.(Kernel.struct(schema), params) 21 | 22 | operation_func = fn -> 23 | run_callbacks(before_save, changeset) 24 | run_callbacks(before_create, changeset) 25 | 26 | case repo.insert(changeset) do 27 | {:ok, record} -> 28 | run_callbacks(after_save, record) 29 | run_callbacks(after_create, changeset) 30 | 31 | {:ok, record} 32 | 33 | error -> 34 | error 35 | end 36 | end 37 | 38 | run_with_or_without_transaction(operation_func, use_transaction, repo) 39 | end 40 | 41 | def unquote(:"#{operation_name}update")(record, params) do 42 | repo = unquote(operation[:repo]) 43 | use_transaction = unquote(operation[:use_transaction]) 44 | before_update = unquote(operation[:before_update]) 45 | after_update = unquote(operation[:after_update]) 46 | before_save = unquote(operation[:before_save]) 47 | after_save = unquote(operation[:after_save]) 48 | 49 | operation_func = fn -> 50 | run_callbacks(before_update, params) 51 | run_callbacks(before_save, record) 52 | {:ok, record} = repo.update(record) 53 | run_callbacks(after_update, record) 54 | run_callbacks(after_save, record) 55 | 56 | {:ok, record} 57 | end 58 | 59 | run_with_or_without_transaction(operation_func, use_transaction, repo) 60 | end 61 | 62 | def unquote(:"#{operation_name}delete")(record) do 63 | repo = unquote(operation[:repo]) 64 | use_transaction = unquote(operation[:use_transaction]) 65 | before_delete = unquote(operation[:before_delete]) 66 | after_delete = unquote(operation[:after_delete]) 67 | 68 | operation_func = fn -> 69 | run_callbacks(before_delete, record) 70 | {:ok, _} = repo.delete(record) 71 | run_callbacks(after_delete, record) 72 | 73 | {:ok, record} 74 | end 75 | 76 | run_with_or_without_transaction(operation_func, use_transaction, repo) 77 | end 78 | 79 | defp run_with_or_without_transaction(operation_func, use_transaction, repo) do 80 | if use_transaction do 81 | case Ecto.Multi.new() 82 | |> Ecto.Multi.run(:operation, fn _repo, _changes -> operation_func.() end) 83 | |> repo.transaction() do 84 | {:error, %{operation: result}} -> 85 | {:error, result} 86 | 87 | {:ok, %{operation: result}} -> 88 | {:ok, result} 89 | 90 | {:error, :operation, result, _} -> 91 | {:error, result} 92 | 93 | # or raise? 94 | a -> 95 | IO.puts("nothing to do on transaction result") 96 | IO.inspect(a) 97 | nil 98 | end 99 | else 100 | operation_func.() 101 | end 102 | end 103 | 104 | defp run_callbacks(nil, _params), do: :ok 105 | 106 | defp run_callbacks(callbacks, params) when is_list(callbacks) do 107 | Enum.each(callbacks, fn callback -> 108 | apply(__MODULE__, callback, [params]) 109 | end) 110 | end 111 | end 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/finders.ex: -------------------------------------------------------------------------------- 1 | defmodule Autocontext.Finders do 2 | defmacro __using__(opts) do 3 | repo = Keyword.get(opts, :repo) 4 | schema = Keyword.get(opts, :schema) 5 | 6 | quote do 7 | import Ecto.Query, warn: false 8 | @repo unquote(repo) 9 | @schema unquote(schema) 10 | 11 | def all do 12 | @repo.all(@schema) 13 | end 14 | 15 | # Accounts.find!(39) 16 | def find!(id), do: @repo.get!(@schema, id) 17 | 18 | # Accounts.find_by!(id: 39) 19 | def find_by!(conds), do: @repo.get_by!(@schema, conds) 20 | 21 | def change( 22 | struct, 23 | attrs \\ %{} 24 | ) do 25 | @schema.changeset(struct, attrs) 26 | end 27 | 28 | defoverridable all: 0, find!: 1, change: 1 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Autocontext.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :autocontext, 7 | version: "0.1.0", 8 | elixir: "~> 1.14", 9 | package: package(), 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | description: "Autocontext acts as ActiveRecord callbacks", 13 | elixirc_paths: elixirc_paths(Mix.env()) 14 | ] 15 | end 16 | 17 | # Run "mix help compile.app" to learn about applications. 18 | def application do 19 | [ 20 | extra_applications: [:logger] 21 | ] 22 | end 23 | 24 | # Run "mix help deps" to learn about dependencies. 25 | defp deps do 26 | [ 27 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 28 | {:ecto, "~> 3.10.0"}, 29 | {:ecto_sql, "~> 3.7"}, 30 | {:postgrex, ">= 0.0.0"}, 31 | {:mox, "~> 1.0", only: :test} 32 | 33 | # {:dep_from_hexpm, "~> 0.3.0"}, 34 | # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} 35 | ] 36 | end 37 | 38 | defp package do 39 | [ 40 | name: :taglet, 41 | files: ["lib", "mix.exs", "README*"], 42 | maintainers: ["michelson"], 43 | licenses: ["MIT"], 44 | links: %{ 45 | "GitHub" => "https://github.com/michelson/autocontext", 46 | "Docs" => "https://hexdocs.pm/michelson/autocontext.html" 47 | } 48 | ] 49 | end 50 | 51 | defp elixirc_paths(:dev), do: ["lib"] 52 | defp elixirc_paths(:test), do: ["lib", "test/support"] 53 | defp elixirc_paths(:ci), do: ["lib", "test/support"] 54 | end 55 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "db_connection": {:hex, :db_connection, "2.5.0", "bb6d4f30d35ded97b29fe80d8bd6f928a1912ca1ff110831edcd238a1973652c", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c92d5ba26cd69ead1ff7582dbb860adeedfff39774105a4f1c92cbb654b55aa2"}, 3 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.33", "3c3fd9673bb5dcc9edc28dd90f50c87ce506d1f71b70e3de69aa8154bc695d44", [:mix], [], "hexpm", "2d526833729b59b9fdb85785078697c72ac5e5066350663e5be6a1182da61b8f"}, 5 | "ecto": {:hex, :ecto, "3.10.3", "eb2ae2eecd210b4eb8bece1217b297ad4ff824b4384c0e3fdd28aaf96edd6135", [:mix], [{:decimal, "~> 1.6 or ~> 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", "44bec74e2364d491d70f7e42cd0d690922659d329f6465e89feb8a34e8cd3433"}, 6 | "ecto_sql": {:hex, :ecto_sql, "3.10.1", "6ea6b3036a0b0ca94c2a02613fd9f742614b5cfe494c41af2e6571bb034dd94c", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.10.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6a25bdbbd695f12c8171eaff0851fa4c8e72eec1e98c7364402dda9ce11c56b"}, 7 | "ex_doc": {:hex, :ex_doc, "0.30.2", "7a3e63ddb387746925bbbbcf6e9cb00e43c757cc60359a2b40059aea573e3e57", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "5ba8cb61d069012f16b50e575b0e3e6cf4083935f7444fab0d92c9314ce86bb6"}, 8 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 9 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [: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", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 10 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 11 | "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"}, 12 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 13 | "postgrex": {:hex, :postgrex, "0.17.1", "01c29fd1205940ee55f7addb8f1dc25618ca63a8817e56fac4f6846fc2cddcbe", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "14b057b488e73be2beee508fb1955d8db90d6485c6466428fe9ccf1d6692a555"}, 14 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 15 | } 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230710132002_create_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Autocontext.Repo.Migrations.CreateUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :name, :string 7 | add :email, :string 8 | add :age, :integer 9 | 10 | timestamps() 11 | end 12 | 13 | create unique_index(:users, [:email]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230710132127_create_accounts.exs: -------------------------------------------------------------------------------- 1 | defmodule Autocontext.Repo.Migrations.CreateAccounts do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:accounts) do 6 | add :name, :string 7 | add :user_id, references(:users, on_delete: :nothing), null: false 8 | 9 | timestamps() 10 | end 11 | 12 | create index(:accounts, [:user_id]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/autocontext_test.exs: -------------------------------------------------------------------------------- 1 | defmodule AutocontextTest do 2 | use ExUnit.Case 3 | doctest Autocontext 4 | 5 | test "greets the world" do 6 | assert Autocontext.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/callbacks_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Autocontext.CallbacksTest do 2 | use ExUnit.Case, async: true 3 | import Mox 4 | import Ecto.Query, only: [from: 2] 5 | alias Autocontext.Repo 6 | alias Autocontext.Accounts 7 | alias Autocontext.User 8 | 9 | setup do 10 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Autocontext.Repo) 11 | 12 | # unless tags[:async] do 13 | Ecto.Adapters.SQL.Sandbox.mode(Autocontext.Repo, {:shared, self()}) 14 | # end 15 | end 16 | 17 | @valid_attrs %{name: "john_doe", email: "john_doe@example.com", age: "10"} 18 | 19 | test "create user with callbacks transactional" do 20 | Autocontext.CallbackMock 21 | |> expect(:validate_username, fn changeset -> changeset end) 22 | |> expect(:hash_password, fn changeset -> changeset end) 23 | 24 | assert {:error, %Ecto.Changeset{}} = Accounts.foo_create(%{}) 25 | 26 | assert {:ok, %User{}} = Accounts.foo_create(@valid_attrs) 27 | end 28 | 29 | test "create/1 successfully creates a User with valid attributes" do 30 | {:ok, user} = Accounts.create(@valid_attrs) 31 | 32 | assert user.name == "john_doe" 33 | assert user.email == "john_doe@example.com" 34 | end 35 | 36 | test "foo_create/1 successfully creates a User with valid attributes" do 37 | {:ok, user} = Accounts.foo_create(@valid_attrs) 38 | assert user.name == "john_doe" 39 | assert user.email == "john_doe@example.com" 40 | end 41 | 42 | test "finders" do 43 | {:ok, user} = Accounts.create(@valid_attrs) 44 | 45 | assert [%Autocontext.User{}] = Accounts.all() 46 | assert %Autocontext.User{} = Accounts.find_by!(id: user.id) 47 | assert %Autocontext.User{} = Accounts.find!(user.id) 48 | end 49 | 50 | test "create/1 returns an error with invalid attributes" do 51 | {:error, changeset} = Accounts.create(%{}) 52 | 53 | assert changeset.valid? == false 54 | end 55 | 56 | test "create_foo/1 returns an error with invalid attributes" do 57 | {:error, changeset} = Accounts.foo_create(%{}) 58 | 59 | assert changeset.valid? == false 60 | end 61 | 62 | test "before_save callbacks are applied correctly" do 63 | Accounts.create(@valid_attrs) 64 | 65 | query = from(u in User, where: u.name == "john_doe") 66 | user = Repo.one(query) 67 | 68 | # Replace this with actual assertions for your before_save callbacks 69 | assert user.name == "john_doe" 70 | end 71 | 72 | test "after_save callbacks are applied correctly" do 73 | {:ok, user} = Accounts.create(@valid_attrs) 74 | 75 | # Replace this with actual assertions for your after_save callbacks 76 | assert user.name == "john_doe" 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /test/support/callback_mock.ex: -------------------------------------------------------------------------------- 1 | defmodule Autocontext.CallbackMock do 2 | import Mox 3 | 4 | Mox.defmock(Autocontext.CallbackBehaviourMock, for: Autocontext.CallbackBehaviour) 5 | end 6 | -------------------------------------------------------------------------------- /test/support/models.ex: -------------------------------------------------------------------------------- 1 | defmodule Autocontext.CallbackBehaviour do 2 | @callback before_create(Ecto.Changeset.t()) :: Ecto.Changeset.t() 3 | @callback after_create(Ecto.Changeset.t()) :: Ecto.Changeset.t() 4 | @callback before_save(Ecto.Changeset.t()) :: Ecto.Changeset.t() 5 | @callback after_save(Ecto.Changeset.t()) :: Ecto.Changeset.t() 6 | @callback before_update(Ecto.Changeset.t()) :: Ecto.Changeset.t() 7 | @callback after_update(Ecto.Changeset.t()) :: Ecto.Changeset.t() 8 | @callback before_delete(Ecto.Changeset.t()) :: Ecto.Changeset.t() 9 | @callback after_delete(Ecto.Changeset.t()) :: Ecto.Changeset.t() 10 | @callback validate_username(Ecto.Changeset.t()) :: Ecto.Changeset.t() 11 | @callback hash_password(Ecto.Changeset.t()) :: Ecto.Changeset.t() 12 | @callback foo_create(Ecto.Changeset.t()) :: Ecto.Changeset.t() 13 | end 14 | 15 | defmodule Autocontext.User do 16 | use Ecto.Schema 17 | import Ecto.Changeset 18 | 19 | if Mix.env() == :test do 20 | def callback_module, do: Autocontext.CallbackMock 21 | else 22 | def callback_module, do: __MODULE__ 23 | end 24 | 25 | schema "users" do 26 | field(:email, :string) 27 | field(:name, :string) 28 | field(:age, :integer) 29 | 30 | timestamps() 31 | end 32 | 33 | def changeset(user, attrs) do 34 | user 35 | |> cast(attrs, [:name, :email, :age]) 36 | |> validate_required([:name, :email, :age]) 37 | |> unique_constraint(:email) 38 | end 39 | end 40 | 41 | defmodule Autocontext.Accounts do 42 | @behaviour Autocontext.CallbackBehaviour 43 | 44 | use Autocontext.Finders, 45 | schema: Autocontext.User, 46 | repo: Autocontext.Repo 47 | 48 | use Autocontext.EctoCallbacks, 49 | operations: [ 50 | [ 51 | repo: Autocontext.Repo, 52 | schema: Autocontext.User, 53 | changeset: &Autocontext.User.changeset/2, 54 | use_transaction: false, 55 | before_save: [:validate_username, :hash_password], 56 | after_save: [:send_welcome_email, :track_user_creation] 57 | ], 58 | [ 59 | name: :foo, 60 | repo: Autocontext.Repo, 61 | schema: Autocontext.User, 62 | changeset: &Autocontext.User.changeset/2, 63 | use_transaction: true, 64 | before_save: [:validate_username], 65 | after_save: [:send_welcome_email] 66 | ] 67 | ] 68 | 69 | def validate_username(changeset) do 70 | IO.puts("VALIDATE USERNAME") 71 | changeset 72 | end 73 | 74 | def hash_password(changeset) do 75 | IO.puts("HASH PASSWORD") 76 | changeset 77 | end 78 | 79 | def send_welcome_email(user) do 80 | IO.puts("SEND WELCOME EMAIL!!") 81 | user 82 | end 83 | 84 | def track_user_creation(user) do 85 | IO.puts("TRACK USER CREATION") 86 | user 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /test/support/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Autocontext.Repo do 2 | use Ecto.Repo, 3 | otp_app: :autocontext, 4 | adapter: Ecto.Adapters.Postgres 5 | end 6 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # ExUnit.start() 2 | 3 | # Mix.Task.run("ecto.create", ~w(-r Autocontext.Repo)) 4 | # Mix.Task.run("ecto.migrate", ~w(-r Autocontext.Repo)) 5 | 6 | Autocontext.Repo.start_link() 7 | Ecto.Adapters.SQL.Sandbox.mode(Autocontext.Repo, :manual) 8 | 9 | Mox.defmock(Autocontext.CallbackMock, for: Autocontext.CallbackBehaviour) 10 | 11 | ExUnit.start() 12 | --------------------------------------------------------------------------------