├── .formatter.exs ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── bin └── ci │ └── init-db.sh ├── config ├── .credo.exs ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── coveralls.json ├── lib └── ecto_trail │ ├── changelog.ex │ └── ecto_trail.ex ├── mix.exs ├── mix.lock ├── priv └── repo │ └── migrations │ ├── 20170419082821_create_log_changes_table.exs │ ├── 20170419082822_create_resources_table.exs │ ├── 20170621091222_add_items_into_resource_table.exs │ ├── 20170621091223_create_comments_and_categories_tables.exs │ ├── 20170621091522_add_list_and_map_into_resource_table.exs │ ├── 20170621171954_add_location_to_resource_table.exs │ └── 20190206082821_create_custom_log_changes_table.exs └── test ├── support └── data_case.ex ├── test_helper.exs └── unit └── ecto_trail_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Don't commit benchmark snapshots 20 | bench/snapshots 21 | 22 | # Don't commit editor configs 23 | .idea 24 | *.iws 25 | /out/ 26 | atlassian-ide-plugin.xml 27 | *.tmlanguage.cache 28 | *.tmPreferences.cache 29 | *.stTheme.cache 30 | *.sublime-workspace 31 | sftp-config.json 32 | GitHub.sublime-settings 33 | .tags 34 | .tags_sorted_by_file 35 | .vagrant 36 | .DS_Store 37 | 38 | # Ignore released binaries 39 | .deliver 40 | 41 | # Don't commit file uploads 42 | uploads/ 43 | !uploads/.gitkeep 44 | 45 | # Don't ever commit this files to docker 46 | *.p12 47 | *id_rsa* 48 | *.key 49 | *.csr 50 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | cache: 3 | directories: 4 | - deps 5 | - _build 6 | services: 7 | - postgresql 8 | addons: 9 | postgresql: "9.5" 10 | apt: 11 | packages: 12 | - postgresql-9.5-postgis-2.3 13 | 14 | elixir: 15 | - 1.8.1 16 | otp_release: 17 | - 21.2.5 18 | env: 19 | global: 20 | - MIX_ENV=test 21 | - MAIN_BRANCHES="master develop staging" # Branches on which you want version to be incremented 22 | before_install: 23 | # Expose MQ and DB to Docker container 24 | - sudo ./bin/ci/init-db.sh 25 | script: 26 | # Run all tests except pending ones 27 | - mix test --exclude pending --trace 28 | # Submit code coverage report to Coveralls 29 | - mix coveralls.travis --exclude pending 30 | # Run static code analysis 31 | - mix credo --strict 32 | # Check code style 33 | - mix format --check-formatted 34 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Nebo #15 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EctoTrail 2 | [![Hex.pm Downloads](https://img.shields.io/hexpm/dw/ecto_trail.svg?maxAge=3600)](https://hex.pm/packages/ecto_trail) [![Latest Version](https://img.shields.io/hexpm/v/ecto_trail.svg?maxAge=3600)](https://hex.pm/packages/ecto_trail) [![License](https://img.shields.io/hexpm/l/ecto_trail.svg?maxAge=3600)](https://hex.pm/packages/ecto_trail) [![Build Status](https://travis-ci.org/Nebo15/ecto_trail.svg?branch=master)](https://travis-ci.org/Nebo15/ecto_trail) [![Coverage Status](https://coveralls.io/repos/github/Nebo15/ecto_trail/badge.svg?branch=master)](https://coveralls.io/github/Nebo15/ecto_trail?branch=master) [![Ebert](https://ebertapp.io/github/Nebo15/ecto_trail.svg)](https://ebertapp.io/github/Nebo15/ecto_trail) 3 | 4 | EctoTrail allows to store changeset changes into a separate `audit_log` table. 5 | 6 | ## Installation and usage 7 | 8 | 1. Add `ecto_trail` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [{:ecto_trail, "~> 0.4.0"}] 13 | end 14 | ``` 15 | 16 | 2. Ensure `ecto_trail` is started before your application: 17 | 18 | ```elixir 19 | def application do 20 | [extra_applications: [:ecto_trail]] 21 | end 22 | ``` 23 | 24 | 3. Add a migration that creates `audit_log` table to `priv/repo/migrations` folder: 25 | 26 | ```elixir 27 | defmodule EctoTrail.TestRepo.Migrations.CreateAuditLogTable do 28 | @moduledoc false 29 | use Ecto.Migration 30 | 31 | def change do 32 | create table(:audit_log, primary_key: false) do 33 | add :id, :uuid, primary_key: true 34 | add :actor_id, :string, null: false 35 | add :resource, :string, null: false 36 | add :resource_id, :string, null: false 37 | add :changeset, :map, null: false 38 | 39 | timestamps([type: :utc_datetime, updated_at: false]) 40 | end 41 | end 42 | end 43 | ``` 44 | 45 | 4. Use `EctoTrail` in your repo: 46 | 47 | ```elixir 48 | defmodule MyApp.Repo do 49 | use Ecto.Repo, otp_app: :my_app 50 | use EctoTrail 51 | end 52 | ``` 53 | 54 | 5. Configure table name which is used to store audit log (in `config.ex`): 55 | 56 | ```elixir 57 | config :ecto_trail, table_name: "audit_log" 58 | ``` 59 | 60 | 6. Use logging functions instead of defaults. See `EctoTrail` module docs. 61 | 62 | ## Docs 63 | 64 | The docs can be found at [https://hexdocs.pm/ecto_trail](https://hexdocs.pm/ecto_trail). 65 | -------------------------------------------------------------------------------- /bin/ci/init-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | POSTGRES_VERSION=9.5 4 | 5 | echo "listen_addresses = '*'" >> /etc/postgresql/${POSTGRES_VERSION}/main/postgresql.conf 6 | echo "host all all 0.0.0.0/0 trust" >> /etc/postgresql/${POSTGRES_VERSION}/main/pg_hba.conf 7 | 8 | service postgresql stop 9 | service postgresql start ${POSTGRES_VERSION} 10 | -------------------------------------------------------------------------------- /config/.credo.exs: -------------------------------------------------------------------------------- 1 | %{ 2 | configs: [ 3 | %{ 4 | name: "default", 5 | files: %{ 6 | included: ["lib/"] 7 | }, 8 | checks: [ 9 | {Credo.Check.Design.TagTODO, exit_status: 0}, 10 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 120}, 11 | {Credo.Check.Readability.Specs, exit_status: 0} 12 | ] 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :ecto_trail, 4 | table_name: "audit_log" 5 | 6 | import_config "#{Mix.env()}.exs" 7 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | config :ex_unit, capture_log: true 3 | config :ecto_trail, sql_sandbox: true 4 | 5 | config :ecto_trail, TestRepo, 6 | pool: Ecto.Adapters.SQL.Sandbox, 7 | database: "ecto_trail_test", 8 | username: "postgres", 9 | password: "postgres", 10 | hostname: "localhost", 11 | types: EctoTrail.PostgresTypes 12 | 13 | config :ecto_trail, TestRepoWithCustomSchema, 14 | pool: Ecto.Adapters.SQL.Sandbox, 15 | database: "ecto_trail_test", 16 | username: "postgres", 17 | password: "postgres", 18 | hostname: "localhost", 19 | types: EctoTrail.PostgresTypes 20 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "skip_files": [ 3 | "test/*" 4 | ], 5 | "custom_stop_words": [ 6 | "field", 7 | "has_one", 8 | "has_many", 9 | "embeds_one", 10 | "embeds_many", 11 | "schema", 12 | "send" 13 | ], 14 | "treat_no_relevant_lines_as_covered": true 15 | } 16 | -------------------------------------------------------------------------------- /lib/ecto_trail/changelog.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoTrail.Changelog do 2 | @moduledoc """ 3 | This is schema that used to store changes in DB. 4 | """ 5 | use Ecto.Schema 6 | 7 | @primary_key {:id, :binary_id, autogenerate: true} 8 | schema Application.fetch_env!(:ecto_trail, :table_name) do 9 | field(:actor_id, :string) 10 | field(:resource, :string) 11 | field(:resource_id, :string) 12 | field(:changeset, :map) 13 | 14 | timestamps(type: :utc_datetime_usec, updated_at: false) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/ecto_trail/ecto_trail.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoTrail do 2 | @moduledoc """ 3 | EctoTrail allows to store changeset changes into a separate `audit_log` table. 4 | 5 | ## Usage 6 | 7 | 1. Add `ecto_trail` to your list of dependencies in `mix.exs`: 8 | 9 | def deps do 10 | [{:ecto_trail, "~> 0.1.0"}] 11 | end 12 | 13 | 2. Ensure `ecto_trail` is started before your application: 14 | 15 | def application do 16 | [extra_applications: [:ecto_trail]] 17 | end 18 | 19 | 3. Add a migration that creates `audit_log` table to `priv/repo/migrations` folder: 20 | 21 | defmodule EctoTrail.TestRepo.Migrations.CreateAuditLogTable do 22 | @moduledoc false 23 | use Ecto.Migration 24 | 25 | def change do 26 | create table(:audit_log, primary_key: false) do 27 | add :id, :uuid, primary_key: true 28 | add :actor_id, :string, null: false 29 | add :resource, :string, null: false 30 | add :resource_id, :string, null: false 31 | add :changeset, :map, null: false 32 | 33 | timestamps([type: :utc_datetime, updated_at: false]) 34 | end 35 | end 36 | end 37 | 38 | 4. Use `EctoTrail` in your repo: 39 | 40 | defmodule MyApp.Repo do 41 | use Ecto.Repo, otp_app: :my_app 42 | use EctoTrail 43 | end 44 | 45 | 5. Use logging functions instead of defaults. See `EctoTrail` module docs. 46 | 47 | You can configure audit_log table name (default `audit_log`) in config: 48 | 49 | config :ecto_trail, 50 | table_name: "custom_audit_log_name" 51 | 52 | If you use multiple Repo and `audit_log` should be stored in tables with different names, 53 | you can configure Schema module for each Repo: 54 | 55 | defmodule MyApp.Repo do 56 | use Ecto.Repo, otp_app: :my_app 57 | use EctoTrail, schema: My.Custom.ChangeLogSchema 58 | end 59 | """ 60 | alias Ecto.{Changeset, Multi} 61 | alias EctoTrail.Changelog 62 | require Logger 63 | 64 | defmacro __using__(opts) do 65 | schema = Keyword.get(opts, :schema, Changelog) 66 | 67 | quote do 68 | @doc """ 69 | Call `c:Ecto.Repo.insert/2` operation and store changes in a `change_log` table. 70 | 71 | Insert arguments, return and options same as `c:Ecto.Repo.insert/2` has. 72 | """ 73 | @spec insert_and_log( 74 | struct_or_changeset :: Ecto.Schema.t() | Ecto.Changeset.t(), 75 | actor_id :: String.T, 76 | opts :: Keyword.t() 77 | ) :: 78 | {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} 79 | def insert_and_log(struct_or_changeset, actor_id, opts \\ []), 80 | do: EctoTrail.insert_and_log(__MODULE__, struct_or_changeset, actor_id, opts) 81 | 82 | @doc """ 83 | Call `c:Ecto.Repo.update/2` operation and store changes in a `change_log` table. 84 | 85 | Insert arguments, return and options same as `c:Ecto.Repo.update/2` has. 86 | """ 87 | @spec update_and_log( 88 | changeset :: Ecto.Changeset.t(), 89 | actor_id :: String.T, 90 | opts :: Keyword.t() 91 | ) :: 92 | {:ok, Ecto.Schema.t()} 93 | | {:error, Ecto.Changeset.t()} 94 | def update_and_log(changeset, actor_id, opts \\ []), 95 | do: EctoTrail.update_and_log(__MODULE__, changeset, actor_id, opts) 96 | 97 | @doc """ 98 | Call `c:Ecto.Repo.audit_schema/0` operation and get Ecto Schema struct for change_log table. 99 | 100 | Return Ecto Schema struct for change_log table. 101 | """ 102 | @spec audit_log_schema :: atom() 103 | def audit_log_schema, do: struct(unquote(schema)) 104 | end 105 | end 106 | 107 | @doc """ 108 | Call `c:Ecto.Repo.insert/2` operation and store changes in a `change_log` table. 109 | 110 | Insert arguments, return and options same as `c:Ecto.Repo.insert/2` has. 111 | """ 112 | @spec insert_and_log( 113 | repo :: Ecto.Repo.t(), 114 | struct_or_changeset :: Ecto.Schema.t() | Ecto.Changeset.t(), 115 | actor_id :: String.T, 116 | opts :: Keyword.t() 117 | ) :: 118 | {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} 119 | def insert_and_log(repo, struct_or_changeset, actor_id, opts \\ []) do 120 | Multi.new() 121 | |> Multi.insert(:operation, struct_or_changeset, opts) 122 | |> run_logging_transaction(repo, struct_or_changeset, actor_id) 123 | end 124 | 125 | @doc """ 126 | Call `c:Ecto.Repo.update/2` operation and store changes in a `change_log` table. 127 | 128 | Insert arguments, return and options same as `c:Ecto.Repo.update/2` has. 129 | """ 130 | @spec update_and_log( 131 | repo :: Ecto.Repo.t(), 132 | changeset :: Ecto.Changeset.t(), 133 | actor_id :: String.T, 134 | opts :: Keyword.t() 135 | ) :: 136 | {:ok, Ecto.Schema.t()} 137 | | {:error, Ecto.Changeset.t()} 138 | def update_and_log(repo, changeset, actor_id, opts \\ []) do 139 | Multi.new() 140 | |> Multi.update(:operation, changeset, opts) 141 | |> run_logging_transaction(repo, changeset, actor_id) 142 | end 143 | 144 | defp run_logging_transaction(multi, repo, struct_or_changeset, actor_id) do 145 | multi 146 | |> Multi.run(:changelog, fn repo, acc -> 147 | log_changes(repo, acc, struct_or_changeset, actor_id) 148 | end) 149 | |> repo.transaction() 150 | |> build_result() 151 | end 152 | 153 | defp build_result({:ok, %{operation: operation}}), 154 | do: {:ok, operation} 155 | 156 | defp build_result({:error, :operation, reason, _changes_so_far}), 157 | do: {:error, reason} 158 | 159 | defp log_changes(repo, multi_acc, struct_or_changeset, actor_id) do 160 | %{operation: operation} = multi_acc 161 | associations = operation.__struct__.__schema__(:associations) 162 | resource = operation.__struct__.__schema__(:source) 163 | embeds = operation.__struct__.__schema__(:embeds) 164 | 165 | changes = 166 | struct_or_changeset 167 | |> get_changes() 168 | |> get_embed_changes(embeds) 169 | |> get_assoc_changes(associations) 170 | 171 | result = 172 | %{ 173 | actor_id: to_string(actor_id), 174 | resource: resource, 175 | resource_id: to_string(operation.id), 176 | changeset: changes 177 | } 178 | |> changelog_changeset(repo) 179 | |> repo.insert() 180 | 181 | case result do 182 | {:ok, changelog} -> 183 | {:ok, changelog} 184 | 185 | {:error, reason} -> 186 | Logger.error( 187 | "Failed to store changes in audit log: #{inspect(struct_or_changeset)} " <> 188 | "by actor #{inspect(actor_id)}. Reason: #{inspect(reason)}" 189 | ) 190 | 191 | {:ok, reason} 192 | end 193 | end 194 | 195 | defp get_changes(%Changeset{changes: changes}), 196 | do: map_custom_ecto_types(changes) 197 | 198 | defp get_changes(changes) when is_map(changes), 199 | do: changes |> Changeset.change(%{}) |> get_changes() 200 | 201 | defp get_changes(changes) when is_list(changes), 202 | do: 203 | changes 204 | |> Enum.map_reduce([], fn ch, acc -> {nil, List.insert_at(acc, -1, get_changes(ch))} end) 205 | |> elem(1) 206 | 207 | defp get_embed_changes(changeset, embeds) do 208 | Enum.reduce(embeds, changeset, fn embed, changeset -> 209 | case Map.get(changeset, embed) do 210 | nil -> 211 | changeset 212 | 213 | embed_changes -> 214 | Map.put(changeset, embed, get_changes(embed_changes)) 215 | end 216 | end) 217 | end 218 | 219 | defp get_assoc_changes(changeset, associations) do 220 | Enum.reduce(associations, changeset, fn assoc, changeset -> 221 | case Map.get(changeset, assoc) do 222 | nil -> 223 | changeset 224 | 225 | assoc_changes -> 226 | Map.put(changeset, assoc, get_changes(assoc_changes)) 227 | end 228 | end) 229 | end 230 | 231 | defp map_custom_ecto_types(changes) do 232 | Enum.into(changes, %{}, &map_custom_ecto_type/1) 233 | end 234 | 235 | defp map_custom_ecto_type({_field, %Changeset{}} = input), do: input 236 | 237 | defp map_custom_ecto_type({field, value}) when is_map(value) do 238 | case Map.has_key?(value, :__struct__) do 239 | true -> {field, inspect(value)} 240 | false -> {field, value} 241 | end 242 | end 243 | 244 | defp map_custom_ecto_type(value), do: value 245 | 246 | defp changelog_changeset(attrs, repo) do 247 | Changeset.cast(repo.audit_log_schema(), attrs, ~w(actor_id resource resource_id changeset)a) 248 | end 249 | end 250 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoTrail.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.4.4" 5 | 6 | def project do 7 | [ 8 | app: :ecto_trail, 9 | description: description(), 10 | package: package(), 11 | version: @version, 12 | elixir: "~> 1.7", 13 | elixirc_paths: elixirc_paths(Mix.env()), 14 | compilers: [] ++ Mix.compilers(), 15 | build_embedded: Mix.env() == :prod, 16 | start_permanent: Mix.env() == :prod, 17 | deps: deps(), 18 | test_coverage: [tool: ExCoveralls], 19 | preferred_cli_env: [coveralls: :test], 20 | docs: [source_ref: "v#\{@version\}", main: "readme", extras: ["README.md"]] 21 | ] 22 | end 23 | 24 | def description do 25 | "This package allows to add audit log that is based on Ecto changesets and stored in a separate table." 26 | end 27 | 28 | def application do 29 | [extra_applications: [:logger, :ecto]] 30 | end 31 | 32 | defp elixirc_paths(:test), do: ["lib", "test/support"] 33 | defp elixirc_paths(_), do: ["lib"] 34 | 35 | defp deps do 36 | [ 37 | {:ecto_sql, "~> 3.7.0"}, 38 | {:postgrex, "~> 0.15.0 or ~> 0.16.0", optional: true}, 39 | {:dialyxir, "~> 0.5", only: [:dev, :test], runtime: false}, 40 | {:geo_postgis, "~> 3.1.0", only: [:dev, :test]}, 41 | {:ex_doc, ">= 0.15.0", only: [:dev, :test]}, 42 | {:excoveralls, ">= 0.5.0", only: [:dev, :test]}, 43 | {:credo, ">= 0.5.1", only: [:dev, :test]} 44 | ] 45 | end 46 | 47 | defp package do 48 | [ 49 | contributors: ["Nebo #15"], 50 | maintainers: ["Nebo #15"], 51 | licenses: ["LISENSE.md"], 52 | links: %{github: "https://github.com/Nebo15/ecto_trail"}, 53 | files: ~w(lib LICENSE.md mix.exs README.md) 54 | ] 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "01d479edba0569a7b7a2c8bf923feeb6dc6a358edc2965ef69aea9ba288bb243"}, 4 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 5 | "credo": {:hex, :credo, "1.0.2", "88bc918f215168bf6ce7070610a6173c45c82f32baa08bdfc80bf58df2d103b6", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "df2e5ad6f6d40b140fa109b465325ee718d54f23f56a4aa9178796ede2a1ab83"}, 6 | "db_connection": {:hex, :db_connection, "2.4.0", "d04b1b73795dae60cead94189f1b8a51cc9e1f911c234cc23074017c43c031e5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ad416c21ad9f61b3103d254a71b63696ecadb6a917b36f563921e0de00d7d7c8"}, 7 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 8 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm", "6c32a70ed5d452c6650916555b1f96c79af5fc4bf286997f8b15f213de786f73"}, 9 | "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm", "000aaeff08919e95e7aea13e4af7b2b9734577b3e6a7c50ee31ee88cab6ec4fb"}, 10 | "ecto": {:hex, :ecto, "3.7.0", "0b250b4aa5a9cdb80252802bd535c54c963e2d83f5bd179a57c093ed0779994b", [: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", "3a212cecd544a6f3d00921bc3e7545070eb50b9a1454525323027bf07eba1165"}, 11 | "ecto_sql": {:hex, :ecto_sql, "3.7.0", "2fcaad4ab0c8d76a5afbef078162806adbe709c04160aca58400d5cbbe8eeac6", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a26135dfa1d99bf87a928c464cfa25bba6535a4fe761eefa56077a4febc60f70"}, 12 | "ex_doc": {:hex, :ex_doc, "0.19.3", "3c7b0f02851f5fc13b040e8e925051452e41248f685e40250d7e40b07b9f8c10", [:mix], [{:earmark, "~> 1.2", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0e11d67e662142fc3945b0ee410c73c8c956717fbeae4ad954b418747c734973"}, 13 | "excoveralls": {:hex, :excoveralls, "0.10.5", "7c912c4ec0715a6013647d835c87cde8154855b9b84e256bc7a63858d5f284e3", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "176052589b681f44de12fb85182ccf0296030f619b6939926bc647dabedf9320"}, 14 | "geo": {:hex, :geo, "3.1.0", "727e005262430d037e870ff364e65d80ca5ca21d5ac8eddd57a1ada72c3f83b0", [:mix], [], "hexpm", "0918a4766e8ca0306571c9503f5f01beecf2485fd1ec25438c6e833faca2e5ef"}, 15 | "geo_postgis": {:hex, :geo_postgis, "3.1.0", "d06c8fa5fd140a52a5c9dab4ad6623a696dd7d99dd791bb361d3f94942442ff9", [:mix], [{:geo, "~> 3.1", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "88e286136534bc25416b67db46dff7c67febb882631bf4fb34c9c9e4c1e4c9ab"}, 16 | "hackney": {:hex, :hackney, "1.15.0", "287a5d2304d516f63e56c469511c42b016423bcb167e61b611f6bad47e3ca60e", [:rebar3], [{:certifi, "2.4.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "b69d97134f1876ba8e4e2f405e9da8cba7cf4f2da0b7cc24a5ccef8dcf1b46b2"}, 17 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"}, 18 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 19 | "makeup": {:hex, :makeup, "0.8.0", "9cf32aea71c7fe0a4b2e9246c2c4978f9070257e5c9ce6d4a28ec450a839b55f", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "5fbc8e549aa9afeea2847c0769e3970537ed302f93a23ac612602e805d9d1e7f"}, 20 | "makeup_elixir": {:hex, :makeup_elixir, "0.13.0", "be7a477997dcac2e48a9d695ec730b2d22418292675c75aa2d34ba0909dcdeda", [:mix], [{:makeup, "~> 0.8", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "adf0218695e22caeda2820eaba703fa46c91820d53813a2223413da3ef4ba515"}, 21 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 22 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm", "7a4c8e1115a2732a67d7624e28cf6c9f30c66711a9e92928e745c255887ba465"}, 23 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm", "5c040b8469c1ff1b10093d3186e2e10dbe483cd73d79ec017993fb3985b8a9b3"}, 24 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, 25 | "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, 26 | "postgrex": {:hex, :postgrex, "0.15.10", "2809dee1b1d76f7cbabe570b2a9285c2e7b41be60cf792f5f2804a54b838a067", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {: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]}], "hexpm", "1560ca427542f6b213f8e281633ae1a3b31cdbcd84ebd7f50628765b8f6132be"}, 27 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm", "603561dc0fd62f4f2ea9b890f4e20e1a0d388746d6e20557cafb1b16950de88c"}, 28 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 29 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"}, 30 | } 31 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170419082821_create_log_changes_table.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoTrail.TestRepo.Migrations.CreateAuditLogTable do 2 | @moduledoc false 3 | use Ecto.Migration 4 | 5 | @table_name String.to_atom(Application.fetch_env!(:ecto_trail, :table_name)) 6 | 7 | def change(table_name \\ @table_name) do 8 | create table(table_name, primary_key: false) do 9 | add(:id, :uuid, primary_key: true) 10 | add(:actor_id, :string, null: false) 11 | add(:resource, :string, null: false) 12 | add(:resource_id, :string, null: false) 13 | add(:changeset, :map, null: false) 14 | 15 | timestamps(type: :utc_datetime_usec, updated_at: false) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170419082822_create_resources_table.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoTrail.TestRepo.Migrations.CreateResourcesTable do 2 | @moduledoc false 3 | use Ecto.Migration 4 | 5 | def change do 6 | create table(:resources) do 7 | add :name, :string 8 | add :data, :map 9 | 10 | timestamps() 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170621091222_add_items_into_resource_table.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoTrail.TestRepo.Migrations.AddItemsIntoResourcesTable do 2 | @moduledoc false 3 | use Ecto.Migration 4 | 5 | def change do 6 | alter table(:resources) do 7 | add :items, {:array, :map} 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170621091223_create_comments_and_categories_tables.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoTrail.TestRepo.Migrations.CreateCommentsAndCategoriesTables do 2 | @moduledoc false 3 | use Ecto.Migration 4 | 5 | def change do 6 | create table(:comments) do 7 | add :title, :string, null: false 8 | add :resource_id, references(:resources, on_delete: :nothing) 9 | end 10 | 11 | create table(:categories) do 12 | add :title, :string, null: false 13 | add :resource_id, references(:resources, on_delete: :nothing) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170621091522_add_list_and_map_into_resource_table.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoTrail.TestRepo.Migrations.AddListAndMapIntoResourcesTable do 2 | @moduledoc false 3 | use Ecto.Migration 4 | 5 | def change do 6 | alter table(:resources) do 7 | add :array, {:array, :string} 8 | add :map, :map 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170621171954_add_location_to_resource_table.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoTrail.TestRepo.Migrations.AddLocationToResourceTable do 2 | use Ecto.Migration 3 | 4 | def change do 5 | execute "CREATE EXTENSION IF NOT EXISTS postgis" 6 | alter table(:resources) do 7 | add :location, :geometry 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /priv/repo/migrations/20190206082821_create_custom_log_changes_table.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoTrail.TestRepo.Migrations.CreateCustomAuditLogTable do 2 | @moduledoc false 3 | use Ecto.Migration 4 | 5 | def change do 6 | create table(:custom_audit_log, primary_key: false) do 7 | add(:id, :uuid, primary_key: true) 8 | add(:actor_id, :string, null: false) 9 | add(:resource, :string, null: false) 10 | add(:resource_id, :string, null: false) 11 | add(:changeset, :map, null: false) 12 | 13 | timestamps(type: :utc_datetime, updated_at: false) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoTrail.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | it cannot be async. For this reason, every test runs 11 | inside a transaction which is reset at the beginning 12 | of the test unless the test case is marked as async. 13 | """ 14 | 15 | use ExUnit.CaseTemplate 16 | 17 | alias Ecto.Adapters.SQL.Sandbox 18 | 19 | using do 20 | quote do 21 | alias TestRepo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import EctoTrail.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | :ok = Sandbox.checkout(TestRepo) 32 | :ok = Sandbox.checkout(TestRepoWithCustomSchema) 33 | 34 | unless tags[:async] do 35 | Sandbox.mode(TestRepo, {:shared, self()}) 36 | Sandbox.mode(TestRepoWithCustomSchema, {:shared, self()}) 37 | end 38 | 39 | :ok 40 | end 41 | 42 | @doc """ 43 | Helper for returning list of errors in a struct when given certain data. 44 | ## Examples 45 | Given a User schema that lists `:name` as a required field and validates 46 | `:password` to be safe, it would return: 47 | iex> errors_on(%User{}, %{password: "password"}) 48 | [password: "is unsafe", name: "is blank"] 49 | You could then write your assertion like: 50 | assert {:password, "is unsafe"} in errors_on(%User{}, %{password: "password"}) 51 | """ 52 | def errors_on(struct, data) do 53 | data 54 | |> (&struct.__struct__.changeset(struct, &1)).() 55 | |> Enum.flat_map(fn {key, errors} -> for msg <- errors, do: {key, msg} end) 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # Enable PostGIS for Ecto 2 | Postgrex.Types.define( 3 | EctoTrail.PostgresTypes, 4 | [Geo.PostGIS.Extension] ++ Ecto.Adapters.Postgres.extensions(), 5 | json: Jason 6 | ) 7 | 8 | defmodule TestRepo do 9 | use Ecto.Repo, 10 | otp_app: :ecto_trail, 11 | adapter: Ecto.Adapters.Postgres 12 | 13 | use EctoTrail 14 | end 15 | 16 | defmodule TestRepoWithCustomSchema do 17 | use Ecto.Repo, 18 | otp_app: :ecto_trail, 19 | adapter: Ecto.Adapters.Postgres 20 | 21 | use EctoTrail, schema: TestCustomChangelog 22 | end 23 | 24 | defmodule TestCustomChangelog do 25 | use Ecto.Schema 26 | 27 | @primary_key {:id, :binary_id, autogenerate: true} 28 | schema "custom_audit_log" do 29 | field(:actor_id, :string) 30 | field(:resource, :string) 31 | field(:resource_id, :string) 32 | field(:changeset, :map) 33 | 34 | timestamps(type: :utc_datetime, updated_at: false) 35 | end 36 | end 37 | 38 | defmodule Comment do 39 | use Ecto.Schema 40 | 41 | schema "comments" do 42 | field(:title, :string) 43 | belongs_to(:resource, Resource) 44 | end 45 | 46 | def changeset(%Comment{} = schema, attrs) do 47 | Ecto.Changeset.cast(schema, attrs, [:title]) 48 | end 49 | end 50 | 51 | defmodule Category do 52 | use Ecto.Schema 53 | 54 | schema "categories" do 55 | field(:title, :string) 56 | belongs_to(:resource, Resource) 57 | end 58 | 59 | def changeset(%Category{} = schema, attrs) do 60 | Ecto.Changeset.cast(schema, attrs, [:title]) 61 | end 62 | end 63 | 64 | defmodule Resource do 65 | @moduledoc false 66 | use Ecto.Schema 67 | 68 | schema "resources" do 69 | field(:name, :string) 70 | field(:array, {:array, :string}) 71 | field(:map, :map) 72 | field(:location, Geo.PostGIS.Geometry) 73 | 74 | embeds_one :data, Data, primary_key: false do 75 | field(:key1, :string) 76 | field(:key2, :string) 77 | end 78 | 79 | embeds_many :items, Item, primary_key: false do 80 | field(:name, :string) 81 | end 82 | 83 | has_many(:comments, Comment) 84 | has_one(:category, {"categories", Category}, on_replace: :delete) 85 | 86 | timestamps() 87 | end 88 | 89 | def embed_changeset(schema, attrs) do 90 | Ecto.Changeset.cast(schema, attrs, [:key1, :key2]) 91 | end 92 | 93 | def embeds_many_changeset(schema, attrs) do 94 | Ecto.Changeset.cast(schema, attrs, [:name]) 95 | end 96 | end 97 | 98 | # Start Postgrex 99 | {:ok, _pids} = Application.ensure_all_started(:postgrex) 100 | 101 | # Create DB 102 | _ = TestRepo.__adapter__().storage_up(TestRepo.config()) 103 | 104 | # Start Repo 105 | {:ok, _pid} = TestRepo.start_link() 106 | {:ok, _pid} = TestRepoWithCustomSchema.start_link() 107 | 108 | # Migrate DB 109 | migrations_path = Path.join([:code.priv_dir(:ecto_trail), "repo", "migrations"]) 110 | Ecto.Migrator.run(TestRepo, migrations_path, :up, all: true) 111 | 112 | # Start ExUnit 113 | ExUnit.start() 114 | 115 | Ecto.Adapters.SQL.Sandbox.mode(TestRepo, :manual) 116 | Ecto.Adapters.SQL.Sandbox.mode(TestRepoWithCustomSchema, :manual) 117 | -------------------------------------------------------------------------------- /test/unit/ecto_trail_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoTrailTest do 2 | use EctoTrail.DataCase 3 | alias EctoTrail.Changelog 4 | alias Ecto.Changeset 5 | doctest EctoTrail 6 | 7 | describe "insert_and_log/3" do 8 | test "logs changes when schema is inserted" do 9 | result = TestRepo.insert_and_log(%Resource{name: "name"}, "cowboy") 10 | assert {:ok, %Resource{name: "name"}} = result 11 | 12 | resource = TestRepo.one(Resource) 13 | resource_id = to_string(resource.id) 14 | 15 | assert %{ 16 | changeset: %{}, 17 | actor_id: "cowboy", 18 | resource_id: ^resource_id, 19 | resource: "resources" 20 | } = TestRepo.one(Changelog) 21 | end 22 | 23 | test "logs changes when changeset is inserted" do 24 | result = 25 | %Resource{} 26 | |> Changeset.change(%{name: "My name"}) 27 | |> TestRepo.insert_and_log("cowboy") 28 | 29 | assert {:ok, %Resource{name: "My name"}} = result 30 | 31 | resource = TestRepo.one(Resource) 32 | resource_id = to_string(resource.id) 33 | 34 | assert %{ 35 | changeset: %{"name" => "My name"}, 36 | actor_id: "cowboy", 37 | resource_id: ^resource_id, 38 | resource: "resources" 39 | } = TestRepo.one(Changelog) 40 | end 41 | 42 | test "logs changes when changeset is empty" do 43 | result = 44 | %Resource{} 45 | |> Changeset.change(%{}) 46 | |> TestRepo.insert_and_log("cowboy") 47 | 48 | assert {:ok, %Resource{name: nil}} = result 49 | 50 | resource = TestRepo.one(Resource) 51 | resource_id = to_string(resource.id) 52 | 53 | assert %{ 54 | changeset: changes, 55 | actor_id: "cowboy", 56 | resource_id: ^resource_id, 57 | resource: "resources" 58 | } = TestRepo.one(Changelog) 59 | 60 | assert %{} == changes 61 | end 62 | 63 | test "logs changes when changeset with embed is inserted" do 64 | attrs = %{ 65 | name: "My name", 66 | array: ["apple", "banana"], 67 | map: %{longitude: 50.45000, latitude: 30.52333}, 68 | location: %Geo.Point{coordinates: {49.44, 17.87}}, 69 | data: %{key2: "key2"}, 70 | category: %{"title" => "test"}, 71 | comments: [ 72 | %{"title" => "wow"}, 73 | %{"title" => "very impressive"} 74 | ], 75 | items: [ 76 | %{name: "Morgan"}, 77 | %{name: "Freeman"} 78 | ] 79 | } 80 | 81 | result = 82 | %Resource{} 83 | |> Changeset.cast(attrs, [:name, :array, :map, :location]) 84 | |> Changeset.cast_embed(:data, with: &Resource.embed_changeset/2) 85 | |> Changeset.cast_embed(:items, with: &Resource.embeds_many_changeset/2) 86 | |> Changeset.cast_assoc(:category) 87 | |> Changeset.cast_assoc(:comments) 88 | |> TestRepo.insert_and_log("cowboy") 89 | 90 | assert {:ok, %Resource{name: "My name"}} = result 91 | 92 | resource = TestRepo.one(Resource) 93 | resource_id = to_string(resource.id) 94 | 95 | assert %{ 96 | changeset: changes, 97 | actor_id: "cowboy", 98 | resource_id: ^resource_id, 99 | resource: "resources" 100 | } = TestRepo.one(Changelog) 101 | 102 | assert %{ 103 | "name" => "My name", 104 | "data" => %{"key2" => "key2"}, 105 | "category" => %{"title" => "test"}, 106 | "comments" => [ 107 | %{"title" => "wow"}, 108 | %{"title" => "very impressive"} 109 | ], 110 | "items" => [ 111 | %{"name" => "Morgan"}, 112 | %{"name" => "Freeman"} 113 | ], 114 | "location" => 115 | "%Geo.Point{coordinates: {49.44, 17.87}, properties: %{}, srid: nil}", 116 | "array" => ["apple", "banana"], 117 | "map" => %{"latitude" => 30.52333, "longitude" => 50.45} 118 | } == changes 119 | end 120 | 121 | test "logs changes when changeset is inserted for Repo with custom schema" do 122 | result = 123 | %Resource{} 124 | |> Changeset.change(%{name: "My name"}) 125 | |> TestRepoWithCustomSchema.insert_and_log("cowboy") 126 | 127 | assert {:ok, %Resource{name: "My name"}} = result 128 | 129 | resource = TestRepoWithCustomSchema.one(Resource) 130 | resource_id = to_string(resource.id) 131 | 132 | assert %{ 133 | changeset: %{"name" => "My name"}, 134 | actor_id: "cowboy", 135 | resource_id: ^resource_id, 136 | resource: "resources" 137 | } = TestRepoWithCustomSchema.one(TestCustomChangelog) 138 | end 139 | 140 | test "returns error when changeset is invalid" do 141 | changeset = 142 | %Resource{} 143 | |> Changeset.change(%{name: "My name"}) 144 | |> Changeset.add_error(:name, "invalid") 145 | 146 | result = TestRepo.insert_and_log(changeset, "cowboy") 147 | assert {:error, %Changeset{valid?: false}} = result 148 | 149 | assert [] == TestRepo.all(Resource) 150 | assert [] == TestRepo.all(Changelog) 151 | end 152 | end 153 | 154 | describe "update_and_log/3" do 155 | setup do 156 | {:ok, schema} = TestRepo.insert(%Resource{name: "name"}) 157 | {:ok, %{schema: schema}} 158 | end 159 | 160 | test "logs changes when changeset is inserted", %{schema: schema} do 161 | result = 162 | schema 163 | |> Changeset.change(%{name: "My new name"}) 164 | |> TestRepo.update_and_log("cowboy") 165 | 166 | assert {:ok, %Resource{name: "My new name"}} = result 167 | 168 | resource = TestRepo.one(Resource) 169 | resource_id = to_string(resource.id) 170 | 171 | assert %{ 172 | changeset: %{"name" => "My new name"}, 173 | actor_id: "cowboy", 174 | resource_id: ^resource_id, 175 | resource: "resources" 176 | } = TestRepo.one(Changelog) 177 | end 178 | 179 | test "returns error when changeset is invalid", %{schema: schema} do 180 | changeset = 181 | schema 182 | |> Changeset.change(%{name: "My new name"}) 183 | |> Changeset.add_error(:name, "invalid") 184 | 185 | result = TestRepo.update_and_log(changeset, "cowboy") 186 | assert {:error, %Changeset{valid?: false}} = result 187 | 188 | assert [%{name: "name"}] = TestRepo.all(Resource) 189 | assert [] == TestRepo.all(Changelog) 190 | end 191 | end 192 | end 193 | --------------------------------------------------------------------------------