├── .dockerignore ├── .editorconfig ├── .env ├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ └── push.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE.md ├── NOTES.md ├── README.md ├── config ├── config.exs ├── dev.exs └── test.exs ├── docker-compose.yaml ├── example ├── .gitignore ├── .iex.exs ├── README.md ├── config │ ├── config.exs │ ├── dev.exs │ └── test.exs ├── lib │ ├── company.ex │ ├── example.ex │ ├── person.ex │ └── repo.ex ├── mix.exs ├── mix.lock ├── priv │ └── repo │ │ └── migrations │ │ ├── 20160715113439_create_companies.exs │ │ ├── 20160715113442_create_people.exs │ │ └── 20160715134921_add_versions.exs └── test │ ├── company_test.exs │ ├── multi_tenant_company_test.exs │ ├── multi_tenant_person_test.exs │ ├── person_test.exs │ ├── support │ └── multi_tenant_helper.exs │ └── test_helper.exs ├── lib ├── mix │ └── tasks │ │ └── papertrail │ │ └── install.ex ├── paper_trail.ex ├── paper_trail │ ├── multi.ex │ ├── repo_client.ex │ ├── serializer.ex │ └── version_queries.ex └── version.ex ├── mix.exs ├── mix.lock ├── package-lock.json ├── package.json ├── priv ├── repo │ └── migrations │ │ ├── 20160619190935_add_users.exs │ │ ├── 20160619190936_add_versions.exs │ │ ├── 20160619190937_add_simple_companies.exs │ │ ├── 20160619190938_add_simple_people.exs │ │ ├── 20170319190938_add_strict_companies.exs │ │ ├── 20170319190940_add_strict_people.exs │ │ └── 20200827222744_add_uniqueness_constraint_to_companies_name.exs ├── uuid_repo │ └── migrations │ │ ├── 20170525133833_create_uuid_products.exs │ │ ├── 20170525142546_create_admins.exs │ │ ├── 20170525142612_create_versions.exs │ │ └── 20170525142613_create_items.exs └── uuid_with_custom_name_repo │ └── migrations │ ├── 20201130190530_create_projects.exs │ ├── 20201130190545_create_people.exs │ └── 20201130190555_create_versions.exs ├── scripts └── test.sh ├── setup-database.sh └── test ├── paper_trail ├── bang_functions_simple_mode_test.exs ├── bang_functions_strict_mode_test.exs ├── base_test.exs └── strict_mode_test.exs ├── support ├── multi_tenant_helper.ex ├── repos.ex ├── simple_models.ex ├── strict_models.ex ├── uuid_models.ex └── uuid_with_custom_name_models.ex ├── test_helper.exs ├── uuid ├── uuid_test.exs └── uuid_with_custom_name_test.exs └── version ├── paper_trail_version_test.exs └── version_queries_test.exs /.dockerignore: -------------------------------------------------------------------------------- 1 | _build/ 2 | deps/ 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # .editorconfig for Elixir projects 4 | # https://git.io/elixir-editorconfig 5 | 6 | # top-most EditorConfig file 7 | root = true 8 | 9 | [*] 10 | indent_style = space 11 | indent_size = 2 12 | end_of_line = lf 13 | charset = utf-8 14 | trim_trailing_whitespace = true 15 | insert_final_newline = true 16 | 17 | [*.{md, markdown, eex}] 18 | trim_trailing_whitespace = false 19 | 20 | [Makefile] 21 | indent_style = tab 22 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REGISTRY="ghcr.io" # set -a && source .env 2 | REPO_OWNER="izelnakri" 3 | PGUSER=postgres 4 | PGPASSWORD=postgres 5 | # PGHOST=localhost 6 | MIX_ENV=test 7 | # CIRCLE_BRANCH=$$(if [ -v CIRCLE_BRANCH ]; then echo main; else git branch --no-color 2> /dev/null | sed -e '/^[^*]/d' -e 's/* \(.*\)/\1/'; fi) 8 | # DOCKER_TAG=paper_trail:main 9 | # DOCKER_TAG=$(echo paper_trail:${CIRCLE_BRANCH} | tr '/' '_') 10 | DOCKER_TAG=$DOCKER_TAG 11 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: docker-compose-based-ci 2 | on: push 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - name: Checkout 8 | uses: actions/checkout@v4 9 | - name: Set ENV variables 10 | run: | 11 | cat .env >> $GITHUB_ENV 12 | echo "REGISTRY=ghcr.io" >> $GITHUB_ENV 13 | echo "REPO_OWNER=$(echo ${GITHUB_REPOSITORY%/*})" >> $GITHUB_ENV 14 | echo "REPO_NAME=$(echo ${GITHUB_REPOSITORY#*/})" >> $GITHUB_ENV 15 | echo "BRANCH_NAME=$(echo ${GITHUB_REF#refs/heads/} | tr / -)" >> $GITHUB_ENV 16 | echo "DOCKER_TAG=$(echo ${GITHUB_REPOSITORY#*/}):$(echo ${GITHUB_REF#refs/heads/} | tr / -)" >> $GITHUB_ENV 17 | - name: Set up Docker Buildx 18 | uses: docker/setup-buildx-action@v3 19 | with: 20 | install: true 21 | - name: Cache Docker layers 22 | uses: actions/cache@v4 23 | with: 24 | path: /tmp/.buildx-cache 25 | key: ${{runner.os}}-buildx-${{github.sha}} 26 | restore-keys: ${{runner.os}}-buildx- 27 | - name: Login to GitHub Container Registry 28 | uses: docker/login-action@v3 29 | with: 30 | registry: ${{env.REGISTRY}} 31 | username: ${{env.REPO_OWNER}} 32 | password: ${{secrets.CR_PAT}} 33 | - name: Build and push 34 | uses: docker/build-push-action@v6 35 | with: 36 | context: . 37 | file: ./Dockerfile 38 | push: true 39 | tags: ${{env.REGISTRY}}/${{env.REPO_OWNER}}/${{env.DOCKER_TAG}} 40 | cache-from: type=local,src=/tmp/.buildx-cache 41 | cache-to: type=local,dest=/tmp/.buildx-cache 42 | - name: Docker compose pull 43 | run: docker compose pull 44 | - name: Docker compose up 45 | run: | 46 | docker compose -f docker-compose.yaml up -d 47 | - name: Execute tests 48 | run: | 49 | docker compose run paper_trail mix test test/paper_trail 50 | docker compose run paper_trail mix test test/version 51 | docker compose run paper_trail mix test test/uuid 52 | STRING_TEST=true docker compose run paper_trail mix test test/uuid 53 | - name: Check Formatted 54 | run: docker compose run paper_trail mix format --check-formatted 55 | -------------------------------------------------------------------------------- /.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 | paper_trail-*.tar 24 | 25 | # Temporary files for e.g. tests 26 | /tmp 27 | 28 | /node_modules/ 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM "elixir:1.17.2-otp-25-alpine" 2 | 3 | ARG MIX_ENV=dev 4 | ENV MIX_ENV=$MIX_ENV 5 | 6 | WORKDIR /code/ 7 | 8 | RUN apk add postgresql | echo "y" | mix local.hex --if-missing && echo "y" | mix local.rebar --if-missing 9 | 10 | ADD ["mix.lock", "mix.exs", "/code/"] 11 | 12 | RUN mix deps.get && MIX_ENV=test mix deps.compile && \ 13 | MIX_ENV=$MIX_ENV mix deps.compile 14 | 15 | ADD ["config", "lib", "priv", "/code/"] 16 | 17 | RUN MIX_ENV=$MIX_ENV mix compile 18 | 19 | ADD ["test", "/code/"] 20 | 21 | RUN MIX_ENV=test mix compile && MIX_ENV=$MIX_ENV mix compile 22 | 23 | ADD . /code/ 24 | 25 | RUN MIX_ENV=test mix compile && MIX_ENV=$MIX_ENV mix compile 26 | 27 | CMD ["/bin/sh"] 28 | 29 | # mix ecto.create 30 | # mix ecto.migrate 31 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2016-present Izel Nakri 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | - PaperTrail.insert_all, update_all, delete_all 2 | - insert_or_update, insert_or_update! 3 | - if I ever do the merging logic keep it in mind that updated_at of the record 4 | must be sourced from the inserted_at of the version/ 5 | ** add PaperTrail.insert!, PaperTrail.update!, PaperTrail.delete! # it shouldnt return a version, it shouldnt give errors/raise?(optional?) 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Hex Version](http://img.shields.io/hexpm/v/paper_trail.svg?style=flat)](https://hex.pm/packages/paper_trail) [![Hex docs](http://img.shields.io/badge/hex.pm-docs-green.svg?style=flat)](https://hexdocs.pm/paper_trail/PaperTrail.html) 2 | [![Total Download](https://img.shields.io/hexpm/dt/paper_trail.svg)](https://hex.pm/packages/paper_trail) 3 | [![License](https://img.shields.io/hexpm/l/paper_trail.svg)](https://github.com/izelnakri/paper_trail/blob/master/LICENSE) 4 | [![Last Updated](https://img.shields.io/github/last-commit/izelnakri/paper_trail.svg)](https://github.com/izelnakri/paper_trail/commits/master) 5 | 6 | # Paper Trail 7 | 8 | Track and record all the changes in your database. Revert back to anytime in history. 9 | 10 | # How does it work? 11 | 12 | PaperTrail lets you record every change in your database in a separate database table called ```versions```. Library generates a new version record with associated data every time you run ```PaperTrail.insert/2```, ```PaperTrail.update/2``` or ```PaperTrail.delete/2``` functions. Simply these functions wrap your Repo insert, update or destroy actions in a database transaction, so if your database action fails you won't get a new version. 13 | 14 | PaperTrail is assailed with hundreds of test assertions for each release. Data integrity is an important aim of this project, please refer to the `strict_mode` if you want to ensure data correctness and integrity of your versions. For simpler use cases the default mode of PaperTrail should suffice. 15 | 16 | ## Example 17 | 18 | ```elixir 19 | changeset = Post.changeset(%Post{}, %{ 20 | title: "Word on the street is Elixir got its own database versioning library", 21 | content: "You should try it now!" 22 | }) 23 | 24 | PaperTrail.insert(changeset) 25 | # => on success: 26 | # {:ok, 27 | # %{model: %Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">, 28 | # title: "Word on the street is Elixir got its own database versioning library", 29 | # content: "You should try it now!", id: 1, inserted_at: ~N[2016-09-15 21:42:38], 30 | # updated_at: ~N[2016-09-15 21:42:38]}, 31 | # version: %PaperTrail.Version{__meta__: #Ecto.Schema.Metadata<:loaded, "versions">, 32 | # event: "insert", id: 1, inserted_at: ~N[2016-09-15 21:42:38], 33 | # item_changes: %{title: "Word on the street is Elixir got its own database versioning library", 34 | # content: "You should try it now!", id: 1, inserted_at: ~N[2016-09-15 21:42:38], 35 | # updated_at: ~N[2016-09-15 21:42:38]}, 36 | # item_id: 1, item_type: "Post", originator_id: nil, originator: nil, meta: nil}}} 37 | 38 | # => on error(it matches Repo.insert/2): 39 | # {:error, Ecto.Changeset, 42 | # valid?: false>, %{}} 43 | 44 | post = Repo.get!(Post, 1) 45 | edit_changeset = Post.changeset(post, %{ 46 | title: "Elixir matures fast", 47 | content: "Future is already here, Elixir is the next step!" 48 | }) 49 | 50 | PaperTrail.update(edit_changeset) 51 | # => on success: 52 | # {:ok, 53 | # %{model: %Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">, 54 | # title: "Elixir matures fast", content: "Future is already here, Elixir is the next step!", 55 | # id: 1, inserted_at: ~N[2016-09-15 21:42:38], 56 | # updated_at: ~N[2016-09-15 22:00:59]}, 57 | # version: %PaperTrail.Version{__meta__: #Ecto.Schema.Metadata<:loaded, "versions">, 58 | # event: "update", id: 2, inserted_at: ~N[2016-09-15 22:00:59], 59 | # item_changes: %{title: "Elixir matures fast", content: "Future is already here, Elixir is the next step!"}, 60 | # item_id: 1, item_type: "Post", originator_id: nil, originator: nil 61 | # meta: nil}}} 62 | 63 | # => on error(it matches Repo.update/2): 64 | # {:error, Ecto.Changeset, 67 | # valid?: false>, %{}} 68 | 69 | PaperTrail.get_version(post) 70 | # %PaperTrail.Version{__meta__: #Ecto.Schema.Metadata<:loaded, "versions">, 71 | # event: "update", id: 2, inserted_at: ~N[2016-09-15 22:00:59], 72 | # item_changes: %{title: "Elixir matures fast", content: "Future is already here, Elixir is the next step!"}, 73 | # item_id: 1, item_type: "Post", originator_id: nil, originator: nil, meta: nil}}} 74 | 75 | updated_post = Repo.get!(Post, 1) 76 | 77 | PaperTrail.delete(updated_post) 78 | # => on success: 79 | # {:ok, 80 | # %{model: %Post{__meta__: #Ecto.Schema.Metadata<:deleted, "posts">, 81 | # title: "Elixir matures fast", content: "Future is already here, Elixir is the next step!", 82 | # id: 1, inserted_at: ~N[2016-09-15 21:42:38], 83 | # updated_at: ~N[2016-09-15 22:00:59]}, 84 | # version: %PaperTrail.Version{__meta__: #Ecto.Schema.Metadata<:loaded, "versions">, 85 | # event: "delete", id: 3, inserted_at: ~N[2016-09-15 22:22:12], 86 | # item_changes: %{title: "Elixir matures fast", content: "Future is already here, Elixir is the next step!", 87 | # id: 1, inserted_at: ~N[2016-09-15 21:42:38], 88 | # updated_at: ~N[2016-09-15 22:00:59]}, 89 | # item_id: 1, item_type: "Post", originator_id: nil, originator: nil, meta: nil}}} 90 | 91 | Repo.aggregate(Post, :count, :id) # => 0 92 | PaperTrail.Version.count() # => 3 93 | # same as Repo.aggregate(PaperTrail.Version, :count, :id) 94 | 95 | PaperTrail.Version.last() # returns the last version in the db by inserted_at 96 | # %PaperTrail.Version{__meta__: #Ecto.Schema.Metadata<:loaded, "versions">, 97 | # event: "delete", id: 3, inserted_at: ~N[2016-09-15 22:22:12], 98 | # item_changes: %{"title" => "Elixir matures fast", content: "Future is already here, Elixir is the next step!", "id" => 1, 99 | # "inserted_at" => "2016-09-15T21:42:38", 100 | # "updated_at" => "2016-09-15T22:00:59"}, 101 | # item_id: 1, item_type: "Post", originator_id: nil, originator: nil, meta: nil} 102 | ``` 103 | 104 | PaperTrail is inspired by the ruby gem ```paper_trail```. However, unlike the ```paper_trail``` gem this library actually results in less data duplication, faster and more explicit programming model to version your record changes. 105 | 106 | The library source code is minimal and well tested. It is suggested to read the source code. 107 | 108 | ## Installation 109 | 110 | 1. Add paper_trail to your list of dependencies in `mix.exs`: 111 | 112 | ```elixir 113 | def deps do 114 | [{:paper_trail, "~> 0.14.3"}] 115 | end 116 | ``` 117 | 118 | 2. Configure paper_trail to use your application repo in `config/config.exs`: 119 | 120 | ```elixir 121 | config :paper_trail, repo: YourApplicationName.Repo 122 | # if you don't specify this PaperTrail will assume your repo name is Repo 123 | ``` 124 | 125 | 3. Install and compile your dependency: 126 | 127 | ```mix deps.get && mix compile``` 128 | 129 | 4. Run this command to generate the migration: 130 | 131 | ```mix papertrail.install``` 132 | 133 | You might want to edit the types for `:item_id` or `:originator_id` if you're 134 | using UUID or other types for your primary keys before you execute 135 | `mix ecto.migrate`. 136 | 137 | 5. Run the migration: 138 | 139 | ```mix ecto.migrate``` 140 | 141 | Your application is now ready to collect some history! 142 | 143 | #### Does this work with phoenix? 144 | 145 | YES! Make sure you do the steps above. 146 | 147 | ### %PaperTrail.Version{} fields: 148 | 149 | | Column Name | Type | Description | Entry Method | 150 | | ------------- | ------- | -------------------------- | ------------------------ | 151 | | event | String | either "insert", "update" or "delete" | Library generates | 152 | | item_type | String | model name of the reference record | Library generates | 153 | | item_id | configurable (Integer by default) | model id of the reference record | Library generates | 154 | | item_changes | Map | all the changes in this version as a map | Library generates | 155 | | originator_id | configurable (Integer by default) | foreign key reference to the creator/owner of this change | Optionally set | 156 | | origin | String | short reference to origin(eg. worker:activity-checker, migration, admin:33) | Optionally set | 157 | | meta | Map | any extra optional meta information about the version(eg. %{slug: "ausername", important: true}) | Optionally set | 158 | | inserted_at | Date | inserted_at timestamp | Ecto generates | 159 | 160 | #### Configuring the types 161 | 162 | If you are using UUID or another type for your primary keys, you can configure 163 | the PaperTrail.Version schema to use it. 164 | 165 | ##### Example Config 166 | 167 | ```elixir 168 | config :paper_trail, item_type: Ecto.UUID, 169 | originator_type: Ecto.UUID, 170 | originator_relationship_options: [references: :uuid] 171 | ``` 172 | 173 | ###### Example User 174 | 175 | ```elixir 176 | defmodule Acme.User do 177 | use Ecto.Schema 178 | 179 | @primary_key {:uuid, :binary_id, autogenerate: true} 180 | schema "users" do 181 | field :email, :string 182 | 183 | timestamps() 184 | end 185 | ``` 186 | 187 | Remember to edit the types accordingly in the generated migration. 188 | 189 | ### Version origin references: 190 | 191 | PaperTrail records have a string field called ```origin```. ```PaperTrail.insert/2```, ```PaperTrail.update/2```, ```PaperTrail.delete/2``` functions accept a second argument to describe the origin of this version: 192 | ```elixir 193 | PaperTrail.update(changeset, origin: "migration") 194 | # or: 195 | PaperTrail.update(changeset, origin: "user:1234") 196 | # or: 197 | PaperTrail.delete(changeset, origin: "worker:delete_inactive_users") 198 | # or: 199 | PaperTrail.insert(new_user_changeset, origin: "password_registration") 200 | # or: 201 | PaperTrail.insert(new_user_changeset, origin: "facebook_registration") 202 | ``` 203 | 204 | ### Version originator relationships 205 | 206 | You can specify setter/originator relationship to paper_trail versions with ```originator``` assignment. This feature is only possible by specifying `:originator` keyword list for your application configuration: 207 | 208 | ```elixir 209 | # In your config/config.exs 210 | config :paper_trail, originator: [name: :user, model: YourApp.User] 211 | # For most applications originator should be the user since models can be updated/created/deleted by several users. 212 | ``` 213 | 214 | Note: You will need to recompile your deps after you have added the config for originator. 215 | 216 | Then originator name could be used for querying and preloading. Originator setting must be done via ```:originator``` or originator name that is defined in the paper_trail configuration: 217 | 218 | ```elixir 219 | user = create_user() 220 | # all these set originator_id's for the version records 221 | PaperTrail.insert(changeset, originator: user) 222 | {:ok, result} = PaperTrail.update(edit_changeset, originator: user) 223 | # or you can use :user in the params instead of :originator if this is your config: 224 | # config :paper_trail, originator: [name: :user, model: YourApplication.User] 225 | {:ok, result} = PaperTrail.update(edit_changeset, user: user) 226 | result[:version] |> Repo.preload(:user) |> Map.get(:user) # we can access the user who made the change from the version thanks to originator relationships! 227 | PaperTrail.delete(edit_changeset, user: user) 228 | ``` 229 | 230 | Also make sure you have the foreign-key constraint in the database and in your version migration file. 231 | 232 | ### Storing version meta data 233 | You might want to add some meta data that doesn't belong to ``originator`` and ``origin`` fields. Such data could be stored in one object named ```meta``` in paper_trail versions. Meta field could be passed as the second optional parameter to PaperTrail.insert/2, PaperTrail.update/2, PaperTrail.delete/2 functions: 234 | 235 | ```elixir 236 | company = Company.changeset(%Company{}, %{name: "Acme Inc."}) 237 | |> PaperTrail.insert(meta: %{slug: "acme-llc"}) 238 | 239 | # You can also combine this with an origin: 240 | edited_company = Company.changeset(company, %{name: "Acme LLC"}) 241 | |> PaperTrail.update(origin: "documentation", meta: %{slug: "acme-llc"}) 242 | 243 | # Or even with an originator: 244 | user = create_user() 245 | deleted_company = Company.changeset(edited_company, %{}) 246 | |> PaperTrail.delete(origin: "worker:github", originator: user, meta: %{slug: "acme-llc", important: true}) 247 | ``` 248 | 249 | # Strict mode 250 | This is a feature more suitable for larger applications. Models can keep their version references via foreign key constraints. Therefore it would be impossible to delete the first and current version of a model if the model exists in the database, it also makes querying easier and the whole design more relational database/SQL friendly. In order to enable strict mode: 251 | 252 | ```elixir 253 | # In your config/config.exs 254 | config :paper_trail, strict_mode: true 255 | ``` 256 | 257 | Strict mode expects tracked models to have foreign-key reference to their first_version and current_version. These columns must be named ```first_version_id```, and ```current_version_id``` in their respective model tables. A tracked model example with a migration file: 258 | 259 | ```elixir 260 | # In the migration file: priv/repo/migrations/create_company.exs 261 | defmodule Repo.Migrations.CreateCompany do 262 | def change do 263 | create table(:companies) do 264 | add :name, :string, null: false 265 | add :founded_in, :date 266 | 267 | # null constraints are highly suggested: 268 | add :first_version_id, references(:versions), null: false 269 | add :current_version_id, references(:versions), null: false 270 | 271 | timestamps() 272 | end 273 | 274 | create unique_index(:companies, [:first_version_id]) 275 | create unique_index(:companies, [:current_version_id]) 276 | end 277 | end 278 | 279 | # In the model definition: 280 | defmodule Company do 281 | use Ecto.Schema 282 | 283 | import Ecto.Changeset 284 | 285 | schema "companies" do 286 | field :name, :string 287 | field :founded_in, :date 288 | 289 | belongs_to :first_version, PaperTrail.Version 290 | belongs_to :current_version, PaperTrail.Version, on_replace: :update # on_replace: is important! 291 | 292 | timestamps() 293 | end 294 | 295 | def changeset(struct, params \\ %{}) do 296 | struct 297 | |> cast(params, [:name, :founded_in]) 298 | end 299 | end 300 | ``` 301 | 302 | When you run PaperTrail.insert/2 transaction, ```first_version_id``` and ```current_version_id``` automagically gets assigned for the model. Example: 303 | 304 | ```elixir 305 | company = Company.changeset(%Company{}, %{name: "Acme LLC"}) |> PaperTrail.insert 306 | # {:ok, 307 | # %{model: %Company{__meta__: #Ecto.Schema.Metadata<:loaded, "companies">, 308 | # name: "Acme LLC", founded_in: nil, id: 1, inserted_at: ~N[2016-09-15 21:42:38], 309 | # updated_at: ~N[2016-09-15 21:42:38], first_version_id: 1, current_version_id: 1}, 310 | # version: %PaperTrail.Version{__meta__: #Ecto.Schema.Metadata<:loaded, "versions">, 311 | # event: "insert", id: 1, inserted_at: ~N[2016-09-15 22:22:12], 312 | # item_changes: %{name: "Acme LLC", founded_in: nil, id: 1, inserted_at: ~N[2016-09-15 21:42:38]}, 313 | # originator_id: nil, origin: "unknown", meta: nil}}} 314 | ``` 315 | 316 | When you PaperTrail.update/2 a model, ```current_version_id``` gets updated during the transaction: 317 | 318 | ```elixir 319 | edited_company = Company.changeset(company, %{name: "Acme Inc."}) |> PaperTrail.update(origin: "documentation") 320 | # {:ok, 321 | # %{model: %Company{__meta__: #Ecto.Schema.Metadata<:loaded, "companies">, 322 | # name: "Acme Inc.", founded_in: nil, id: 1, inserted_at: ~N[2016-09-15 21:42:38], 323 | # updated_at: ~N[2016-09-15 23:22:12], first_version_id: 1, current_version_id: 2}, 324 | # version: %PaperTrail.Version{__meta__: #Ecto.Schema.Metadata<:loaded, "versions">, 325 | # event: "update", id: 2, inserted_at: ~N[2016-09-15 23:22:12], 326 | # item_changes: %{name: "Acme Inc."}, originator_id: nil, origin: "documentation", meta: nil}}} 327 | ``` 328 | 329 | Additionally, you can put a null constraint on ```origin``` column, you should always put an ```origin``` reference to describe who makes the change. This is important for big applications because a model can change from many sources. 330 | 331 | ### Bang(!) functions: 332 | 333 | PaperTrail also supports ```PaperTrail.insert!```, ```PaperTrail.update!```, ```PaperTrail.delete!```. Naming of these functions intentionally match ```Repo.insert!```, ```Repo.update!```, ```Repo.delete!``` functions. If PaperTrail is on strict_mode these bang functions will update the version references of the model just like the normal PaperTrail operations. 334 | 335 | Bang functions assume the operation will always be successful, otherwise functions will raise ```Ecto.InvalidChangesetError``` just like ```Repo.insert!```, ```Repo.update!``` and ```Repo.delete!```: 336 | 337 | ```elixir 338 | changeset = Post.changeset(%Post{}, %{ 339 | title: "Word on the street is Elixir got its own database versioning library", 340 | content: "You should try it now!" 341 | }) 342 | 343 | inserted_post = PaperTrail.insert!(changeset) 344 | # => on success: 345 | # %Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">, 346 | # title: "Word on the street is Elixir got its own database versioning library", 347 | # content: "You should try it now!", id: 1, inserted_at: ~N[2016-09-15 21:42:38], 348 | # updated_at: ~N[2016-09-15 21:42:38] 349 | # } 350 | # 351 | # => on error raises: Ecto.InvalidChangesetError !! 352 | 353 | inserted_post_version = PaperTrail.get_version(inserted_post) 354 | # %PaperTrail.Version{__meta__: #Ecto.Schema.Metadata<:loaded, "versions">, 355 | # event: "insert", id: 1, inserted_at: ~N[2016-09-15 21:42:38], 356 | # item_changes: %{title: "Word on the street is Elixir got its own database versioning library", 357 | # content: "You should try it now!", id: 1, inserted_at: ~N[2016-09-15 21:42:38], 358 | # updated_at: ~N[2016-09-15 21:42:38]}, 359 | # item_id: 1, item_type: "Post", originator_id: nil, originator: nil, meta: nil} 360 | 361 | edit_changeset = Post.changeset(inserted_post, %{ 362 | title: "Elixir matures fast", 363 | content: "Future is already here, Elixir is the next step!" 364 | }) 365 | 366 | updated_post = PaperTrail.update!(edit_changeset) 367 | # => on success: 368 | # %Post{__meta__: #Ecto.Schema.Metadata<:loaded, "posts">, 369 | # title: "Elixir matures fast", content: "Future is already here, you deserve to be awesome!", 370 | # id: 1, inserted_at: ~N[2016-09-15 21:42:38], 371 | # updated_at: ~N[2016-09-15 22:00:59]} 372 | # 373 | # => on error raises: Ecto.InvalidChangesetError !! 374 | 375 | updated_post_version = PaperTrail.get_version(updated_post) 376 | # %PaperTrail.Version{__meta__: #Ecto.Schema.Metadata<:loaded, "versions">, 377 | # event: "update", id: 2, inserted_at: ~N[2016-09-15 22:00:59], 378 | # item_changes: %{title: "Elixir matures fast", content: "Future is already here, Elixir is the next step!"}, 379 | # item_id: 1, item_type: "Post", originator_id: nil, originator: nil 380 | # meta: nil} 381 | 382 | PaperTrail.delete!(updated_post) 383 | # => on success: 384 | # %Post{__meta__: #Ecto.Schema.Metadata<:deleted, "posts">, 385 | # title: "Elixir matures fast", content: "Future is already here, Elixir is the next step!", 386 | # id: 1, inserted_at: ~N[2016-09-15 21:42:38], 387 | # updated_at: ~N[2016-09-15 22:00:59]} 388 | # 389 | # => on error raises: Ecto.InvalidChangesetError !! 390 | 391 | PaperTrail.get_version(updated_post) 392 | # %PaperTrail.Version{__meta__: #Ecto.Schema.Metadata<:loaded, "versions">, 393 | # event: "delete", id: 3, inserted_at: ~N[2016-09-15 22:22:12], 394 | # item_changes: %{title: "Elixir matures fast", content: "Future is already here, Elixir is the next step!", 395 | # id: 1, inserted_at: ~N[2016-09-15 21:42:38], 396 | # updated_at: ~N[2016-09-15 22:00:59]}, 397 | # item_id: 1, item_type: "Post", originator_id: nil, originator: nil, meta: nil} 398 | 399 | Repo.aggregate(Post, :count, :id) # => 0 400 | PaperTrail.Version.count() # => 3 401 | # same as Repo.aggregate(PaperTrail.Version, :count, :id) 402 | 403 | PaperTrail.Version.last() # returns the last version in the db by inserted_at 404 | # %PaperTrail.Version{__meta__: #Ecto.Schema.Metadata<:loaded, "versions">, 405 | # event: "delete", id: 3, inserted_at: ~N[2016-09-15 22:22:12], 406 | # item_changes: %{"title" => "Elixir matures fast", content: "Future is already here, Elixir is the next step!", "id" => 1, 407 | # "inserted_at" => "2016-09-15T21:42:38", 408 | # "updated_at" => "2016-09-15T22:00:59"}, 409 | # item_id: 1, item_type: "Post", originator_id: nil, originator: nil, meta: nil} 410 | ``` 411 | 412 | ## Working with multi tenancy 413 | 414 | Sometimes you have to deal with applications where you need multi tenancy capabilities, 415 | and you want to keep tracking of the versions of your data on different schemas (PostgreSQL) 416 | or databases (MySQL). 417 | 418 | You can use the [Ecto.Query prefix](https://hexdocs.pm/ecto/Ecto.Query.html#module-query-prefix) 419 | in order to switch between different schemas/databases for your own data, so 420 | you can specify in your changeset where to store your record. Example: 421 | 422 | ```elixir 423 | tenant = "tenant_id" 424 | changeset = User.changeset(%User{}, %{first_name: "Izel", last_name: "Nakri"}) 425 | 426 | changeset 427 | |> Ecto.Queryable.to_query() 428 | |> Map.put(:prefix, tenant) 429 | |> Repo.insert() 430 | ``` 431 | 432 | PaperTrail also allows you to store the `Version` entries generated by your activity in 433 | different schemas/databases by using the value of the element `:prefix` on the options 434 | of the functions. Example: 435 | 436 | ```elixir 437 | tenant = "tenant_id" 438 | 439 | changeset = 440 | User.changeset(%User{}, %{first_name: "Izel", last_name: "Nakri"}) 441 | |> Ecto.Queryable.to_query() 442 | |> Map.put(:prefix, tenant) 443 | 444 | PaperTrail.insert(changeset, [prefix: tenant]) 445 | ``` 446 | 447 | By doing this, you're storing the new `User` entry into the schema/database 448 | specified by the `:prefix` value (`tenant_id`). 449 | 450 | Note that the `User`'s changeset it's sent with the `:prefix`, so PaperTrail **will take care of the 451 | storage of the generated `Version` entry in the desired schema/database**. Make sure 452 | to add this prefix to your changeset before the execution of the PaperTrail function if you want to do versioning on a separate schema. 453 | 454 | PaperTrail can also get versions of records or models from different schemas/databases as well 455 | by using the `:prefix` option. Example: 456 | 457 | ```elixir 458 | tenant = "tenant_id" 459 | id = 1 460 | 461 | PaperTrail.get_versions(User, id, [prefix: tenant]) 462 | ``` 463 | 464 | ## Version timestamps 465 | 466 | PaperTrail can be configured to use `utc_datetime` or `utc_datetime_usec` for Version timestamps. 467 | 468 | ```elixir 469 | # In your config/config.exs 470 | config :paper_trail, timestamps_type: :utc_datetime 471 | ``` 472 | 473 | Note: You will need to recompile your deps after you have added the config for timestamps. 474 | 475 | ## Postgres datatype support 476 | 477 | PaperTrail serializes the version data in JSON and not all native Postgres data types are supported directly. [Composite types](https://www.postgresql.org/docs/current/rowtypes.html#:~:text=A%20composite%20type%20represents%20the,be%20of%20a%20composite%20type.) and [range types](https://www.postgresql.org/docs/current/rangetypes.html) are two examples which have no native JSON representation. 478 | 479 | Developers may derive their own [Jason encoder](https://hexdocs.pm/jason/Jason.Encoder.html) for such types. It should be noted that an encoder can only be defined for a native Elixir base type or `struct` once in an application and therefore there is a small risk of conflicting encoders. 480 | 481 | ## Suggestions 482 | 483 | - PaperTrail.Version(s) order matter, 484 | - Don't delete your paper_trail versions, instead you can merge them 485 | - If you have a question or a problem, do not hesitate to create an issue or submit a pull request 486 | 487 | ## Contributing 488 | 489 | ``` 490 | set -a 491 | source .env 492 | mix test --trace 493 | ``` 494 | 495 | # Credits 496 | Many thanks to: 497 | - [Jose Pablo Castro](https://github.com/josepablocastro) - Built the repo configuration for paper_trail 498 | - [Harold Tafur](https://github.com/hdtafur) - Built the `:ecto_options` option for PaperTrail inserts 499 | - [Florian Gerhardt](https://github.com/FlorianGerhardt) - Fixed rare compile errors for PaperTrail repos 500 | - [Alex Antonov](https://github.com/asiniy) - Original inventor of the originator feature 501 | - [Moritz Schmale](https://github.com/narrowtux) - UUID primary keys feature 502 | - [Jason Draper](https://github.com/drapergeek) - UUID primary keys feature 503 | - [Jonatan Männchen](https://github.com/maennchen) - Added non-regular :binary_id UUID support for originator 504 | - [Josh Taylor](https://github.com/joshuataylor) - Maintenance and new feature suggestions 505 | - [Mitchell Henke](https://github.com/mitchellhenke) - Fixed weird elixir compiler warnings 506 | - [Iván González](https://github.com/dreamingechoes) - Multi tenancy feature and some minor refactors 507 | - [Teo Choong Ping](https://github.com/seymores) - Fixed paper_trail references for newer Elixir versions 508 | - [devvit](https://github.com/devvit) - Added non-regular primary key tracking support 509 | - [rustamtolipov](https://github.com/rustamtolipov) - Added support for Ecto v3 510 | - [gabrielpra1](https://github.com/gabrielpra1) - Added enhanced support for Ecto.Changeset 511 | - [Darren Thompson](https://github.com/DiscoStarslayer) - Added PaperTrail.Multi which makes paper trail transactions more usable 512 | - [Harold Tafur](https://github.com/hdtafur) - Made PaperTrail.insert accept :ecto_options params(ie. upsert options) 513 | - [Attila Szabo](https://github.com/szaboat) - Made %Version[:inserted_at] accept different ecto datetime options 514 | - [Rafael Scheffer](https://github.com/rschef) - Built PaperTrail.Serializer that unifies %Version{} serialization 515 | - [Kian Meng Ang](https://github.com/kianmeng) - Improved documentation 516 | - [Francisco Correia](https://github.com/fv316) - Made PaperTrail transaction keys and ecto transactions more customizable 517 | - [Don Barlow](https://github.com/ottobar) - Made :initial_version_key configurable for `strict_mode` inserts 518 | - [Christoph Schmatzler](https://github.com/cschmatzler) - Built PaperTrail.insert_or_update feature 519 | - [Izel Nakri](https://github.com/izelnakri) - The Originator of this library. See what I did there ;) 520 | 521 | Additional thanks to: 522 | - [Ruby paper_trail gem](https://github.com/airblade/paper_trail) - Initial inspiration of this project. 523 | - [Ecto](https://github.com/elixir-ecto/ecto) - For the great API. 524 | 525 | ## License 526 | 527 | This source code is licensed under the MIT license. Copyright (c) 2016-present Izel Nakri. 528 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # config :paper_trail, ecto_repos: [] 6 | # This configuration is loaded before any dependency and is restricted 7 | # to this project. If another project depends on this project, this 8 | # file won't be loaded nor affect the parent project. For this reason, 9 | # if you want to provide default values for your application for 10 | # 3rd-party users, it should be done in your "mix.exs" file. 11 | 12 | # You can configure for your application as: 13 | # 14 | # config :paper_trail, key: :value 15 | # 16 | # And access this configuration in your application as: 17 | # 18 | # Application.get_env(:paper_trail, :key) 19 | # 20 | # Or configure a 3rd-party app: 21 | # 22 | # config :logger, level: :info 23 | # 24 | 25 | # It is also possible to import configuration files, relative to this 26 | # directory. For example, you can emulate configuration per environment 27 | # by uncommenting the line below and defining dev.exs, test.exs and such. 28 | # Configuration from the imported file will override the ones defined 29 | # here (which is why it is important to import them last). 30 | # 31 | 32 | import_config "#{Mix.env()}.exs" 33 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :paper_trail, 4 | ecto_repos: [PaperTrail.Repo, PaperTrail.UUIDRepo, PaperTrail.UUIDWithCustomNameRepo] 5 | 6 | config :paper_trail, repo: PaperTrail.Repo, originator: [name: :user, model: User] 7 | 8 | config :paper_trail, PaperTrail.Repo, 9 | adapter: Ecto.Adapters.Postgres, 10 | username: System.get_env("PGUSER"), 11 | password: System.get_env("PGPASSWORD"), 12 | database: "paper_trail_test", 13 | hostname: System.get_env("PGHOST"), 14 | show_sensitive_data_on_connection_error: true, 15 | poolsize: 10 16 | 17 | config :paper_trail, PaperTrail.UUIDRepo, 18 | adapter: Ecto.Adapters.Postgres, 19 | username: System.get_env("PGUSER"), 20 | password: System.get_env("PGPASSWORD"), 21 | database: "paper_trail_uuid_test", 22 | hostname: System.get_env("PGHOST"), 23 | show_sensitive_data_on_connection_error: true, 24 | poolsize: 10 25 | 26 | config :paper_trail, PaperTrail.UUIDWithCustomNameRepo, 27 | adapter: Ecto.Adapters.Postgres, 28 | username: System.get_env("PGUSER"), 29 | password: System.get_env("PGPASSWORD"), 30 | database: "paper_trail_uuid_with_custom_name_test", 31 | hostname: System.get_env("PGHOST"), 32 | show_sensitive_data_on_connection_error: true, 33 | poolsize: 10 34 | 35 | config :logger, level: :warning 36 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:16.4-alpine 4 | environment: 5 | PGPASSWORD: $PGPASSWORD 6 | PGUSER: $PGUSER 7 | POSTGRES_USER: $PGUSER 8 | POSTGRES_PASSWORD: $PGPASSWORD 9 | # PGDATA: /var/lib/postgresql/data/pgdata 10 | restart: always 11 | networks: 12 | - backend_network 13 | # volumes: 14 | # - pgdata:/var/lib/postgresql/data 15 | healthcheck: 16 | test: ["CMD-SHELL", "pg_isready -U ${PGUSER}"] 17 | interval: 1s 18 | timeout: 5s 19 | retries: 10 20 | paper_trail: 21 | image: $REGISTRY/$REPO_OWNER/$DOCKER_TAG 22 | build: 23 | context: . 24 | dockerfile: Dockerfile 25 | cache_from: 26 | - $REGISTRY/$REPO_OWNER/$DOCKER_TAG 27 | environment: 28 | PGUSER: $PGUSER 29 | PGPASSWORD: $PGPASSWORD 30 | PGPORT: 5432 31 | PGHOST: db 32 | MIX_ENV: $MIX_ENV 33 | tty: true 34 | depends_on: 35 | db: 36 | condition: service_healthy 37 | networks: 38 | - backend_network 39 | command: ["/bin/sh", "./setup-database.sh"] 40 | 41 | # volumes: 42 | # pgdata: 43 | networks: 44 | backend_network: 45 | driver: bridge 46 | -------------------------------------------------------------------------------- /example/.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 | -------------------------------------------------------------------------------- /example/.iex.exs: -------------------------------------------------------------------------------- 1 | import Ecto.Query 2 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed as: 8 | 9 | 1. Add `example` to your list of dependencies in `mix.exs`: 10 | 11 | ```elixir 12 | def deps do 13 | [{:example, "~> 0.1.0"}] 14 | end 15 | ``` 16 | 17 | 2. Ensure `example` is started before your application: 18 | 19 | ```elixir 20 | def application do 21 | [extra_applications: [:example]] 22 | end 23 | ``` 24 | 25 | -------------------------------------------------------------------------------- /example/config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | config :example, ecto_repos: [Repo] 6 | 7 | # This configuration is loaded before any dependency and is restricted 8 | # to this project. If another project depends on this project, this 9 | # file won't be loaded nor affect the parent project. For this reason, 10 | # if you want to provide default values for your application for 11 | # 3rd-party users, it should be done in your "mix.exs" file. 12 | 13 | # You can configure for your application as: 14 | # 15 | # config :example, key: :value 16 | # 17 | # And access this configuration in your application as: 18 | # 19 | # Application.get_env(:example, :key) 20 | # 21 | # Or configure a 3rd-party app: 22 | # 23 | # config :logger, level: :info 24 | # 25 | 26 | # It is also possible to import configuration files, relative to this 27 | # directory. For example, you can emulate configuration per environment 28 | # by uncommenting the line below and defining dev.exs, test.exs and such. 29 | # Configuration from the imported file will override the ones defined 30 | # here (which is why it is important to import them last). 31 | # 32 | 33 | import_config "#{Mix.env}.exs" 34 | -------------------------------------------------------------------------------- /example/config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :example, Repo, 4 | adapter: Ecto.Adapters.Postgres, 5 | database: "papertrail_example_dev", 6 | username: "postgres", 7 | password: "postgres", 8 | hostname: "localhost", 9 | poolsize: 10 10 | -------------------------------------------------------------------------------- /example/config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :example, Repo, 4 | adapter: Ecto.Adapters.Postgres, 5 | username: "postgres", 6 | password: "postgres", 7 | database: "paper_trail_example_test", 8 | hostname: "localhost", 9 | poolsize: 10 10 | -------------------------------------------------------------------------------- /example/lib/company.ex: -------------------------------------------------------------------------------- 1 | defmodule Company do 2 | use Ecto.Schema 3 | 4 | import Ecto.Changeset 5 | 6 | schema "companies" do 7 | field :name, :string 8 | field :is_active, :boolean 9 | field :website, :string 10 | field :city, :string 11 | field :address, :string 12 | field :facebook, :string 13 | field :twitter, :string 14 | field :founded_in, :string 15 | 16 | has_many :people, Person 17 | 18 | timestamps() 19 | end 20 | 21 | @optional_fields ~w(name is_active website city address facebook twitter founded_in)a 22 | 23 | def changeset(model, params \\ %{}) do 24 | model 25 | |> cast(params, @optional_fields) 26 | |> cast_assoc(:people, required: false) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /example/lib/example.ex: -------------------------------------------------------------------------------- 1 | defmodule Example do 2 | use Application 3 | 4 | def start(_type, _args) do 5 | import Supervisor.Spec, warn: false 6 | 7 | children = [ 8 | supervisor(Repo, []), 9 | ] 10 | 11 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 12 | # for other strategies and supported options 13 | opts = [strategy: :one_for_one, name: Example.Supervisor] 14 | Supervisor.start_link(children, opts) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /example/lib/person.ex: -------------------------------------------------------------------------------- 1 | defmodule Person do 2 | use Ecto.Schema 3 | 4 | import Ecto.Changeset 5 | 6 | schema "people" do 7 | field :first_name, :string 8 | field :last_name, :string 9 | field :visit_count, :integer 10 | field :gender, :boolean 11 | field :birthdate, :date 12 | 13 | belongs_to :company, Company 14 | 15 | timestamps() 16 | end 17 | 18 | @optional_fields ~w(first_name last_name visit_count gender birthdate company_id)a 19 | 20 | def changeset(model, params \\ %{}) do 21 | model 22 | |> cast(params, @optional_fields) 23 | |> foreign_key_constraint(:company_id) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /example/lib/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Repo do 2 | use Ecto.Repo, otp_app: :example, adapter: Ecto.Adapters.Postgres 3 | end 4 | -------------------------------------------------------------------------------- /example/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Example.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :example, 6 | version: "0.1.0", 7 | elixir: "~> 1.3", 8 | build_embedded: Mix.env == :prod, 9 | start_permanent: Mix.env == :prod, 10 | deps: deps()] 11 | end 12 | 13 | # Configuration for the OTP application 14 | # 15 | # Type "mix help compile.app" for more information 16 | def application do 17 | [ 18 | mod: {Example, []}, 19 | extra_applications: [:logger, :postgrex, :ecto] 20 | ] 21 | end 22 | 23 | # Dependencies can be Hex packages: 24 | # 25 | # {:mydep, "~> 0.3.0"} 26 | # 27 | # Or git/path repositories: 28 | # 29 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 30 | # 31 | # Type "mix help deps" for more examples and options 32 | defp deps do 33 | [ 34 | {:ecto, "~> 3.0-rc", override: true}, 35 | {:ecto_sql, "~> 3.0-rc", override: true}, 36 | {:postgrex, ">= 0.0.0-rc"}, 37 | {:jason, "~> 1.0"}, 38 | {:paper_trail, path: "../"} 39 | ] 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /example/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], []}, 3 | "db_connection": {:hex, :db_connection, "2.0.0-rc.0", "f6960e86b5e524468ec16fb7277e509c784de565ac520213a1813ad2bf7d802f", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"}, 5 | "ecto": {:hex, :ecto, "3.0.0-rc.1", "c966a270b289739d6895f61bee339065a3075d1df34ddd369d400cf0c936fd6c", [:mix], [{:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, 6 | "ecto_sql": {:hex, :ecto_sql, "3.0.0-rc.0", "a61da743812a47174e8b79dbe6aa7d4a9f7e6dbf8c90cfd7015f3767738b37ba", [:mix], [{:db_connection, "~> 2.0-rc.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.0-rc.1", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.0-rc.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0-rc.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.2.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, 7 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, 8 | "paper_trail": {:hex, :paper_trail, "0.7.3", "2e65a18c0928264c2e18dccdba7794b847c90a20cfa3a2fd1e2668d7690452d3", [:mix], [{:ecto, ">= 2.1.0", [hex: :ecto, optional: false]}, {:poison, ">= 3.1.0 or >= 2.0.0", [hex: :poison, optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, optional: false]}]}, 9 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], []}, 10 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, 11 | "postgrex": {:hex, :postgrex, "0.14.0-rc.1", "a88cbeab25c5f3fc505fc6590bd30877a5acf11b448aedb23b41cbc063824ceb", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0-rc.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, 12 | "telemetry": {:hex, :telemetry, "0.2.0", "5b40caa3efe4deb30fb12d7cd8ed4f556f6d6bd15c374c2366772161311ce377", [:mix], [], "hexpm"}, 13 | } 14 | -------------------------------------------------------------------------------- /example/priv/repo/migrations/20160715113439_create_companies.exs: -------------------------------------------------------------------------------- 1 | defmodule Example.Repo.Migrations.CreateCompanies do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:companies) do 6 | add :name, :string 7 | add :is_active, :boolean 8 | add :website, :string 9 | add :city, :string 10 | add :address, :string 11 | add :facebook, :string 12 | add :twitter, :string 13 | add :founded_in, :string 14 | 15 | timestamps() 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /example/priv/repo/migrations/20160715113442_create_people.exs: -------------------------------------------------------------------------------- 1 | defmodule Example.Repo.Migrations.CreatePeople do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:people) do 6 | add :first_name, :string 7 | add :last_name, :string 8 | add :visit_count, :integer 9 | add :gender, :boolean 10 | add :birthdate, :date 11 | 12 | add :company_id, references(:companies), null: false 13 | 14 | timestamps() 15 | end 16 | 17 | create index(:people, [:company_id]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /example/priv/repo/migrations/20160715134921_add_versions.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.AddVersions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:versions) do 6 | add :event, :string 7 | add :item_type, :string 8 | add :item_id, :integer 9 | add :item_changes, :map 10 | add :origin, :string 11 | add :originator_id, references(:people) 12 | add :meta, :map 13 | 14 | add :inserted_at, :utc_datetime, null: false 15 | end 16 | 17 | create index(:versions, [:originator_id]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /example/test/company_test.exs: -------------------------------------------------------------------------------- 1 | defmodule CompanyTest do 2 | use ExUnit.Case 3 | import Ecto.Query 4 | 5 | doctest Company 6 | 7 | setup_all do 8 | Repo.delete_all(Person) 9 | Repo.delete_all(Company) 10 | Repo.delete_all(PaperTrail.Version) 11 | :ok 12 | end 13 | 14 | test "creating a company creates a company version with correct attributes" do 15 | {:ok, result} = 16 | %Company{} 17 | |> Company.changeset(%{name: "Acme LLC", is_active: true, city: "Greenwich", people: []}) 18 | |> PaperTrail.insert(origin: "test") 19 | 20 | company_count = 21 | from(company in Company, select: count(company.id)) 22 | |> Repo.all() 23 | version_count = 24 | from(version in PaperTrail.Version, select: count(version.id)) 25 | |> Repo.all() 26 | first_company = 27 | first(Company, :id) 28 | |> preload(:people) 29 | |> Repo.one() 30 | 31 | company = result[:model] |> Map.drop([:__meta__, :__struct__, :inserted_at, :updated_at, :id]) 32 | version = result[:version] |> Map.drop([:__meta__, :__struct__, :inserted_at]) 33 | 34 | assert company_count == [1] 35 | assert version_count == [1] 36 | 37 | assert company == %{ 38 | name: "Acme LLC", 39 | is_active: true, 40 | city: "Greenwich", 41 | website: nil, 42 | address: nil, 43 | facebook: nil, 44 | twitter: nil, 45 | founded_in: nil, 46 | people: [] 47 | } 48 | 49 | assert Map.drop(version, [:id]) == %{ 50 | event: "insert", 51 | item_type: "Company", 52 | item_id: first_company.id, 53 | item_changes: Map.drop(result[:model], [:__meta__, :__struct__, :people]), 54 | origin: "test", 55 | originator_id: nil, 56 | meta: nil 57 | } 58 | end 59 | 60 | test "updating a company creates a company version with correct item_changes" do 61 | first_company = 62 | first(Company, :id) 63 | |> preload(:people) 64 | |> Repo.one() 65 | 66 | {:ok, result} = 67 | first_company 68 | |> Company.changeset(%{ 69 | city: "Hong Kong", 70 | website: "http://www.acme.com", 71 | facebook: "acme.llc" 72 | }) |> PaperTrail.update() 73 | 74 | company_count = 75 | from(company in Company, select: count(company.id)) 76 | |> Repo.all() 77 | version_count = 78 | from(version in PaperTrail.Version, select: count(version.id)) 79 | |> Repo.all() 80 | 81 | company = result[:model] |> Map.drop([:__meta__, :__struct__, :inserted_at, :updated_at, :id]) 82 | version = result[:version] |> Map.drop([:__meta__, :__struct__, :inserted_at]) 83 | 84 | assert company_count == [1] 85 | assert version_count == [2] 86 | 87 | assert company == %{ 88 | name: "Acme LLC", 89 | is_active: true, 90 | city: "Hong Kong", 91 | website: "http://www.acme.com", 92 | address: nil, 93 | facebook: "acme.llc", 94 | twitter: nil, 95 | founded_in: nil, 96 | people: [] 97 | } 98 | 99 | assert Map.drop(version, [:id]) == %{ 100 | event: "update", 101 | item_type: "Company", 102 | item_id: first_company.id, 103 | item_changes: %{city: "Hong Kong", website: "http://www.acme.com", facebook: "acme.llc"}, 104 | origin: nil, 105 | originator_id: nil, 106 | meta: nil 107 | } 108 | end 109 | 110 | test "deleting a company creates a company version with correct attributes" do 111 | company = 112 | first(Company, :id) 113 | |> preload(:people) 114 | |> Repo.one() 115 | 116 | {:ok, result} = 117 | company 118 | |> PaperTrail.delete() 119 | 120 | company_count = 121 | from(company in Company, select: count(company.id)) 122 | |> Repo.all() 123 | version_count = 124 | from(version in PaperTrail.Version, select: count(version.id)) 125 | |> Repo.all() 126 | 127 | company_ref = result[:model] |> Map.drop([:__meta__, :__struct__, :inserted_at, :updated_at, :id]) 128 | version = result[:version] |> Map.drop([:__meta__, :__struct__, :inserted_at]) 129 | 130 | assert company_count == [0] 131 | assert version_count == [3] 132 | 133 | assert company_ref == %{ 134 | name: "Acme LLC", 135 | is_active: true, 136 | city: "Hong Kong", 137 | website: "http://www.acme.com", 138 | address: nil, 139 | facebook: "acme.llc", 140 | twitter: nil, 141 | founded_in: nil, 142 | people: [] 143 | } 144 | 145 | assert Map.drop(version, [:id]) == %{ 146 | event: "delete", 147 | item_type: "Company", 148 | item_id: company.id, 149 | item_changes: %{ 150 | id: company.id, 151 | inserted_at: company.inserted_at, 152 | updated_at: company.updated_at, 153 | name: "Acme LLC", 154 | is_active: true, 155 | website: "http://www.acme.com", 156 | city: "Hong Kong", 157 | address: nil, 158 | facebook: "acme.llc", 159 | twitter: nil, 160 | founded_in: nil 161 | }, 162 | origin: nil, 163 | originator_id: nil, 164 | meta: nil 165 | } 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /example/test/multi_tenant_company_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MultiTenantCompanyTest do 2 | use ExUnit.Case 3 | import Ecto.Query 4 | 5 | setup_all do 6 | Repo.delete_all(PaperTrail.Version) 7 | MultiTenantHelper.setup_tenant(Repo) 8 | :ok 9 | end 10 | 11 | test "[multi tenant] creating a company creates a company version with correct attributes" do 12 | {:ok, result} = 13 | %Company{} 14 | |> Company.changeset(%{name: "Acme LLC", is_active: true, city: "Greenwich", people: []}) 15 | |> MultiTenantHelper.add_prefix_to_changeset() 16 | |> PaperTrail.insert(origin: "test", prefix: MultiTenantHelper.tenant()) 17 | 18 | company_count = 19 | from(company in Company, select: count(company.id)) 20 | |> MultiTenantHelper.add_prefix_to_query() 21 | |> Repo.all() 22 | version_count = 23 | from(version in PaperTrail.Version, select: count(version.id)) 24 | |> MultiTenantHelper.add_prefix_to_query() 25 | |> Repo.all() 26 | regular_version_count = 27 | from(version in PaperTrail.Version, select: count(version.id)) 28 | |> Repo.all() 29 | first_company = 30 | first(Company, :id) 31 | |> preload(:people) 32 | |> MultiTenantHelper.add_prefix_to_query() 33 | |> Repo.one() 34 | 35 | company = result[:model] |> Map.drop([:__meta__, :__struct__, :inserted_at, :updated_at, :id]) 36 | version = result[:version] |> Map.drop([:__meta__, :__struct__, :inserted_at]) 37 | 38 | assert company_count == [1] 39 | assert version_count == [1] 40 | assert regular_version_count == [0] 41 | 42 | assert company == %{ 43 | name: "Acme LLC", 44 | is_active: true, 45 | city: "Greenwich", 46 | website: nil, 47 | address: nil, 48 | facebook: nil, 49 | twitter: nil, 50 | founded_in: nil, 51 | people: [] 52 | } 53 | 54 | assert Map.drop(version, [:id]) == %{ 55 | event: "insert", 56 | item_type: "Company", 57 | item_id: first_company.id, 58 | item_changes: Map.drop(result[:model], [:__meta__, :__struct__, :people]), 59 | origin: "test", 60 | originator_id: nil, 61 | meta: nil 62 | } 63 | end 64 | 65 | test "[multi tenant] updating a company creates a company version with correct item_changes" do 66 | first_company = 67 | first(Company, :id) 68 | |> preload(:people) 69 | |> MultiTenantHelper.add_prefix_to_query() 70 | |> Repo.one() 71 | 72 | {:ok, result} = 73 | first_company 74 | |> Company.changeset(%{ 75 | city: "Hong Kong", 76 | website: "http://www.acme.com", 77 | facebook: "acme.llc" 78 | }) 79 | |> MultiTenantHelper.add_prefix_to_changeset() 80 | |> PaperTrail.update(prefix: MultiTenantHelper.tenant()) 81 | 82 | company_count = 83 | from(company in Company, select: count(company.id)) 84 | |> MultiTenantHelper.add_prefix_to_query() 85 | |> Repo.all() 86 | version_count = 87 | from(version in PaperTrail.Version, select: count(version.id)) 88 | |> MultiTenantHelper.add_prefix_to_query() 89 | |> Repo.all() 90 | regular_version_count = 91 | from(version in PaperTrail.Version, select: count(version.id)) 92 | |> Repo.all() 93 | 94 | company = result[:model] |> Map.drop([:__meta__, :__struct__, :inserted_at, :updated_at, :id]) 95 | version = result[:version] |> Map.drop([:__meta__, :__struct__, :inserted_at]) 96 | 97 | assert company_count == [1] 98 | assert version_count == [2] 99 | assert regular_version_count == [0] 100 | 101 | assert company == %{ 102 | name: "Acme LLC", 103 | is_active: true, 104 | city: "Hong Kong", 105 | website: "http://www.acme.com", 106 | address: nil, 107 | facebook: "acme.llc", 108 | twitter: nil, 109 | founded_in: nil, 110 | people: [] 111 | } 112 | 113 | assert Map.drop(version, [:id]) == %{ 114 | event: "update", 115 | item_type: "Company", 116 | item_id: first_company.id, 117 | item_changes: %{city: "Hong Kong", website: "http://www.acme.com", facebook: "acme.llc"}, 118 | origin: nil, 119 | originator_id: nil, 120 | meta: nil 121 | } 122 | end 123 | 124 | test "[multi tenant] deleting a company creates a company version with correct attributes" do 125 | company = 126 | first(Company, :id) 127 | |> preload(:people) 128 | |> MultiTenantHelper.add_prefix_to_query() 129 | |> Repo.one() 130 | 131 | {:ok, result} = 132 | company 133 | |> PaperTrail.delete(prefix: MultiTenantHelper.tenant()) 134 | 135 | company_count = 136 | from(company in Company, select: count(company.id)) 137 | |> MultiTenantHelper.add_prefix_to_query() 138 | |> Repo.all() 139 | version_count = 140 | from(version in PaperTrail.Version, select: count(version.id)) 141 | |> MultiTenantHelper.add_prefix_to_query() 142 | |> Repo.all() 143 | regular_version_count = 144 | from(version in PaperTrail.Version, select: count(version.id)) 145 | |> Repo.all() 146 | 147 | company_ref = result[:model] |> Map.drop([:__meta__, :__struct__, :inserted_at, :updated_at, :id]) 148 | version = result[:version] |> Map.drop([:__meta__, :__struct__, :inserted_at]) 149 | 150 | assert company_count == [0] 151 | assert version_count == [3] 152 | assert regular_version_count == [0] 153 | 154 | assert company_ref == %{ 155 | name: "Acme LLC", 156 | is_active: true, 157 | city: "Hong Kong", 158 | website: "http://www.acme.com", 159 | address: nil, 160 | facebook: "acme.llc", 161 | twitter: nil, 162 | founded_in: nil, 163 | people: [] 164 | } 165 | 166 | assert Map.drop(version, [:id]) == %{ 167 | event: "delete", 168 | item_type: "Company", 169 | item_id: company.id, 170 | item_changes: %{ 171 | id: company.id, 172 | inserted_at: company.inserted_at, 173 | updated_at: company.updated_at, 174 | name: "Acme LLC", 175 | is_active: true, 176 | website: "http://www.acme.com", 177 | city: "Hong Kong", 178 | address: nil, 179 | facebook: "acme.llc", 180 | twitter: nil, 181 | founded_in: nil 182 | }, 183 | origin: nil, 184 | originator_id: nil, 185 | meta: nil 186 | } 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /example/test/multi_tenant_person_test.exs: -------------------------------------------------------------------------------- 1 | defmodule MultiTenantPersonTest do 2 | use ExUnit.Case 3 | import Ecto.Query 4 | 5 | setup_all do 6 | Repo.delete_all(PaperTrail.Version) 7 | MultiTenantHelper.setup_tenant(Repo) 8 | 9 | %Company{} 10 | |> Company.changeset(%{name: "Acme LLC", website: "http://www.acme.com"}) 11 | |> MultiTenantHelper.add_prefix_to_changeset() 12 | |> Repo.insert() 13 | 14 | %Company{} 15 | |> Company.changeset(%{name: "Another Company Corp.", is_active: true, address: "Sesame street 100/3, 101010"}) 16 | |> MultiTenantHelper.add_prefix_to_changeset() 17 | |> Repo.insert() 18 | 19 | :ok 20 | end 21 | 22 | test "[multi tenant] creating a person with meta tag creates a person version with correct attributes" do 23 | company = 24 | first(Company, :id) 25 | |> preload(:people) 26 | |> MultiTenantHelper.add_prefix_to_query() 27 | |> Repo.one() 28 | 29 | {:ok, result} = 30 | %Person{} 31 | |> Person.changeset(%{first_name: "Izel", last_name: "Nakri", gender: true, company_id: company.id}) 32 | |> MultiTenantHelper.add_prefix_to_changeset() 33 | |> PaperTrail.insert(origin: "admin", meta: %{}, prefix: MultiTenantHelper.tenant()) 34 | 35 | person_count = 36 | from(person in Person, select: count(person.id)) 37 | |> MultiTenantHelper.add_prefix_to_query() 38 | |> Repo.all() 39 | version_count = 40 | from(version in PaperTrail.Version, select: count(version.id)) 41 | |> MultiTenantHelper.add_prefix_to_query() 42 | |> Repo.all() 43 | regular_version_count = 44 | from(version in PaperTrail.Version, select: count(version.id)) 45 | |> Repo.all() 46 | 47 | person = result[:model] |> Map.drop([:__meta__, :__struct__, :inserted_at, :updated_at, :id]) 48 | version = result[:version] |> Map.drop([:__meta__, :__struct__, :inserted_at]) 49 | 50 | first_person = 51 | first(Person, :id) 52 | |> preload(:company) 53 | |> MultiTenantHelper.add_prefix_to_query() 54 | |> Repo.one() 55 | 56 | assert person_count == [1] 57 | assert version_count == [1] 58 | assert regular_version_count == [0] 59 | 60 | assert Map.drop(person, [:company]) == %{ 61 | first_name: "Izel", 62 | last_name: "Nakri", 63 | gender: true, 64 | visit_count: nil, 65 | birthdate: nil, 66 | company_id: company.id 67 | } 68 | 69 | assert Map.drop(version, [:id]) == %{ 70 | event: "insert", 71 | item_type: "Person", 72 | item_id: first_person.id, 73 | item_changes: Map.drop(result[:model], [:__meta__, :__struct__, :company]), 74 | origin: "admin", 75 | originator_id: nil, 76 | meta: %{} 77 | } 78 | end 79 | 80 | test "[multi tenant] updating a person creates a person version with correct attributes" do 81 | first_person = 82 | first(Person, :id) 83 | |> preload(:company) 84 | |> MultiTenantHelper.add_prefix_to_query() 85 | |> Repo.one() 86 | 87 | target_company = 88 | from(c in Company, where: c.name == "Another Company Corp.", limit: 1) 89 | |> MultiTenantHelper.add_prefix_to_query() 90 | |> Repo.one() 91 | 92 | {:ok, result} = 93 | first_person 94 | |> Person.changeset(%{ 95 | first_name: "Isaac", 96 | visit_count: 10, 97 | birthdate: ~D[1992-04-01], 98 | company_id: target_company.id 99 | }) 100 | |> MultiTenantHelper.add_prefix_to_changeset() 101 | |> PaperTrail.update([origin: "user:1", meta: %{linkname: "izelnakri"}, 102 | prefix: MultiTenantHelper.tenant()]) 103 | 104 | person_count = 105 | from(person in Person, select: count(person.id)) 106 | |> MultiTenantHelper.add_prefix_to_query() 107 | |> Repo.all() 108 | version_count = 109 | from(version in PaperTrail.Version, select: count(version.id)) 110 | |> MultiTenantHelper.add_prefix_to_query() 111 | |> Repo.all() 112 | regular_version_count = 113 | from(version in PaperTrail.Version, select: count(version.id)) 114 | |> Repo.all() 115 | 116 | person = result[:model] |> Map.drop([:__meta__, :__struct__, :inserted_at, :updated_at, :id]) 117 | version = result[:version] |> Map.drop([:__meta__, :__struct__, :inserted_at]) 118 | 119 | assert person_count == [1] 120 | assert version_count == [2] 121 | assert regular_version_count == [0] 122 | 123 | assert Map.drop(person, [:company]) == %{ 124 | company_id: target_company.id, 125 | first_name: "Isaac", 126 | visit_count: 10, 127 | birthdate: ~D[1992-04-01], # this is the only problem 128 | last_name: "Nakri", 129 | gender: true 130 | } 131 | 132 | assert Map.drop(version, [:id]) == %{ 133 | event: "update", 134 | item_type: "Person", 135 | item_id: first_person.id, 136 | item_changes: %{ 137 | first_name: "Isaac", 138 | visit_count: 10, 139 | birthdate: ~D[1992-04-01], 140 | company_id: target_company.id 141 | }, 142 | origin: "user:1", 143 | originator_id: nil, 144 | meta: %{ 145 | linkname: "izelnakri" 146 | } 147 | } 148 | end 149 | 150 | test "[multi tenant] deleting a person creates a person version with correct attributes" do 151 | person = 152 | first(Person, :id) 153 | |> preload(:company) 154 | |> MultiTenantHelper.add_prefix_to_query() 155 | |> Repo.one() 156 | 157 | {:ok, result} = 158 | person 159 | |> PaperTrail.delete(prefix: MultiTenantHelper.tenant()) 160 | 161 | person_count = 162 | from(person in Person, select: count(person.id)) 163 | |> MultiTenantHelper.add_prefix_to_query() 164 | |> Repo.all() 165 | version_count = 166 | from(version in PaperTrail.Version, select: count(version.id)) 167 | |> MultiTenantHelper.add_prefix_to_query() 168 | |> Repo.all() 169 | regular_version_count = 170 | from(version in PaperTrail.Version, select: count(version.id)) 171 | |> Repo.all() 172 | 173 | version = result[:version] |> Map.drop([:__meta__, :__struct__, :inserted_at]) 174 | 175 | assert person_count == [0] 176 | assert version_count == [3] 177 | assert regular_version_count == [0] 178 | 179 | assert Map.drop(version, [:id]) == %{ 180 | event: "delete", 181 | item_type: "Person", 182 | item_id: person.id, 183 | item_changes: %{ 184 | id: person.id, 185 | inserted_at: person.inserted_at, 186 | updated_at: person.updated_at, 187 | first_name: "Isaac", 188 | last_name: "Nakri", 189 | gender: true, 190 | visit_count: 10, 191 | birthdate: ~D[1992-04-01], 192 | company_id: person.company.id 193 | }, 194 | origin: nil, 195 | originator_id: nil, 196 | meta: nil 197 | } 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /example/test/person_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PersonTest do 2 | use ExUnit.Case 3 | import Ecto.Query 4 | 5 | doctest Person 6 | 7 | setup_all do 8 | Repo.delete_all(Person) 9 | Repo.delete_all(Company) 10 | Repo.delete_all(PaperTrail.Version) 11 | 12 | %Company{} 13 | |> Company.changeset(%{name: "Acme LLC", website: "http://www.acme.com"}) 14 | |> Repo.insert() 15 | 16 | %Company{} 17 | |> Company.changeset(%{name: "Another Company Corp.", is_active: true, address: "Sesame street 100/3, 101010"}) 18 | |> Repo.insert() 19 | 20 | :ok 21 | end 22 | 23 | test "creating a person with meta tag creates a person version with correct attributes" do 24 | company = 25 | first(Company, :id) 26 | |> preload(:people) 27 | |> Repo.one() 28 | 29 | {:ok, result} = 30 | %Person{} 31 | |> Person.changeset(%{first_name: "Izel", last_name: "Nakri", gender: true, company_id: company.id}) 32 | |> PaperTrail.insert(origin: "admin", meta: %{}) 33 | 34 | person_count = 35 | from(person in Person, select: count(person.id)) 36 | |> Repo.all() 37 | version_count = 38 | from(version in PaperTrail.Version, select: count(version.id)) 39 | |> Repo.all() 40 | 41 | person = result[:model] |> Map.drop([:__meta__, :__struct__, :inserted_at, :updated_at, :id]) 42 | version = result[:version] |> Map.drop([:__meta__, :__struct__, :inserted_at]) 43 | 44 | first_person = 45 | first(Person, :id) 46 | |> preload(:company) 47 | |> Repo.one() 48 | 49 | assert person_count == [1] 50 | assert version_count == [1] 51 | 52 | assert Map.drop(person, [:company]) == %{ 53 | first_name: "Izel", 54 | last_name: "Nakri", 55 | gender: true, 56 | visit_count: nil, 57 | birthdate: nil, 58 | company_id: company.id 59 | } 60 | 61 | assert Map.drop(version, [:id]) == %{ 62 | event: "insert", 63 | item_type: "Person", 64 | item_id: first_person.id, 65 | item_changes: Map.drop(result[:model], [:__meta__, :__struct__, :company]), 66 | origin: "admin", 67 | originator_id: nil, 68 | meta: %{} 69 | } 70 | end 71 | 72 | test "updating a person creates a person version with correct attributes" do 73 | first_person = 74 | first(Person, :id) 75 | |> preload(:company) 76 | |> Repo.one() 77 | 78 | target_company = 79 | from(c in Company, where: c.name == "Another Company Corp.", limit: 1) 80 | |> Repo.one() 81 | 82 | {:ok, result} = 83 | first_person 84 | |> Person.changeset(%{ 85 | first_name: "Isaac", 86 | visit_count: 10, 87 | birthdate: ~D[1992-04-01], 88 | company_id: target_company.id 89 | }) |> PaperTrail.update(origin: "user:1", meta: %{linkname: "izelnakri"}) 90 | 91 | person_count = 92 | from(person in Person, select: count(person.id)) 93 | |> Repo.all() 94 | version_count = 95 | from(version in PaperTrail.Version, select: count(version.id)) 96 | |> Repo.all() 97 | 98 | person = result[:model] |> Map.drop([:__meta__, :__struct__, :inserted_at, :updated_at, :id]) 99 | version = result[:version] |> Map.drop([:__meta__, :__struct__, :inserted_at]) 100 | 101 | assert person_count == [1] 102 | assert version_count == [2] 103 | 104 | assert Map.drop(person, [:company]) == %{ 105 | company_id: target_company.id, 106 | first_name: "Isaac", 107 | visit_count: 10, 108 | birthdate: ~D[1992-04-01], # this is the only problem 109 | last_name: "Nakri", 110 | gender: true 111 | } 112 | 113 | assert Map.drop(version, [:id]) == %{ 114 | event: "update", 115 | item_type: "Person", 116 | item_id: first_person.id, 117 | item_changes: %{ 118 | first_name: "Isaac", 119 | visit_count: 10, 120 | birthdate: ~D[1992-04-01], 121 | company_id: target_company.id 122 | }, 123 | origin: "user:1", 124 | originator_id: nil, 125 | meta: %{ 126 | linkname: "izelnakri" 127 | } 128 | } 129 | end 130 | 131 | test "deleting a person creates a person version with correct attributes" do 132 | person = 133 | first(Person, :id) 134 | |> preload(:company) 135 | |> Repo.one() 136 | 137 | {:ok, result} = 138 | person 139 | |> PaperTrail.delete() 140 | 141 | person_count = 142 | from(person in Person, select: count(person.id)) 143 | |> Repo.all() 144 | version_count = 145 | from(version in PaperTrail.Version, select: count(version.id)) 146 | |> Repo.all() 147 | 148 | version = result[:version] |> Map.drop([:__meta__, :__struct__, :inserted_at]) 149 | 150 | assert person_count == [0] 151 | assert version_count == [3] 152 | 153 | assert Map.drop(version, [:id]) == %{ 154 | event: "delete", 155 | item_type: "Person", 156 | item_id: person.id, 157 | item_changes: %{ 158 | id: person.id, 159 | inserted_at: person.inserted_at, 160 | updated_at: person.updated_at, 161 | first_name: "Isaac", 162 | last_name: "Nakri", 163 | gender: true, 164 | visit_count: 10, 165 | birthdate: ~D[1992-04-01], 166 | company_id: person.company.id 167 | }, 168 | origin: nil, 169 | originator_id: nil, 170 | meta: nil 171 | } 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /example/test/support/multi_tenant_helper.exs: -------------------------------------------------------------------------------- 1 | defmodule MultiTenantHelper do 2 | alias Ecto.Adapters.SQL 3 | alias Ecto.Changeset 4 | 5 | @migrations_path "migrations" 6 | @tenant "tenant_id" 7 | 8 | def add_prefix_to_changeset(%Changeset{} = changeset) do 9 | %{changeset | data: add_prefix_to_struct(changeset.data)} 10 | end 11 | 12 | def add_prefix_to_query(query) do 13 | query |> Ecto.Queryable.to_query() |> Map.put(:prefix, @tenant) 14 | end 15 | 16 | def add_prefix_to_struct(%{__struct__: _} = model) do 17 | Ecto.put_meta(model, prefix: @tenant) 18 | end 19 | 20 | def setup_tenant(repo, direction \\ :up, opts \\ [all: true]) do 21 | # Drop the previous tenant to reset the data 22 | SQL.query(repo, "DROP SCHEMA \"#{@tenant}\" CASCADE", []) 23 | 24 | opts_with_prefix = Keyword.put(opts, :prefix, @tenant) 25 | 26 | # Create new tenant 27 | SQL.query(repo, "CREATE SCHEMA \"#{@tenant}\"", []) 28 | Ecto.Migrator.run(repo, migrations_path(repo), direction, opts_with_prefix) 29 | end 30 | 31 | def tenant(), do: @tenant 32 | 33 | defp migrations_path(repo), do: Path.join(build_repo_priv(repo), @migrations_path) 34 | 35 | def source_repo_priv(repo) do 36 | repo.config()[:priv] || "priv/#{repo |> Module.split |> List.last |> Macro.underscore}" 37 | end 38 | 39 | def build_repo_priv(repo) do 40 | Application.app_dir(Keyword.fetch!(repo.config(), :otp_app), source_repo_priv(repo)) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /example/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.configure seed: 0 2 | 3 | Mix.Task.run "ecto.create", ~w(-r Repo) 4 | Mix.Task.run "ecto.migrate", ~w(-r Repo) 5 | 6 | Code.require_file("test/support/multi_tenant_helper.exs") 7 | 8 | ExUnit.start 9 | -------------------------------------------------------------------------------- /lib/mix/tasks/papertrail/install.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.Papertrail.Install do 2 | @shortdoc "generates paper_trail migration file for your database" 3 | 4 | use Mix.Task 5 | 6 | import Macro, only: [underscore: 1] 7 | import Mix.Generator 8 | 9 | def run(_args) do 10 | path = Path.relative_to("priv/repo/migrations", Mix.Project.app_path()) 11 | file = Path.join(path, "#{timestamp()}_#{underscore(AddVersions)}.exs") 12 | timestamps_type = Application.get_env(:paper_trail, :timestamps_type, :utc_datetime) 13 | 14 | create_directory(path) 15 | 16 | create_file(file, """ 17 | defmodule Repo.Migrations.AddVersions do 18 | use Ecto.Migration 19 | 20 | def change do 21 | create table(:versions) do 22 | add :event, :string, null: false, size: 10 23 | add :item_type, :string, null: false 24 | add :item_id, :integer 25 | add :item_changes, :map, null: false 26 | add :originator_id, references(:users) # you can change :users to your own foreign key constraint 27 | add :origin, :string, size: 50 28 | add :meta, :map 29 | 30 | # Configure timestamps type in config.ex :paper_trail :timestamps_type 31 | add :inserted_at, :#{timestamps_type}, null: false 32 | end 33 | 34 | create index(:versions, [:originator_id]) 35 | create index(:versions, [:item_id, :item_type]) 36 | # Uncomment if you want to add the following indexes to speed up special queries: 37 | # create index(:versions, [:event, :item_type]) 38 | # create index(:versions, [:item_type, :inserted_at]) 39 | end 40 | end 41 | """) 42 | end 43 | 44 | defp timestamp do 45 | {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() 46 | "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" 47 | end 48 | 49 | defp pad(i) when i < 10, do: <> 50 | defp pad(i), do: to_string(i) 51 | end 52 | -------------------------------------------------------------------------------- /lib/paper_trail.ex: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail do 2 | alias PaperTrail.Version 3 | alias PaperTrail.Serializer 4 | 5 | defdelegate get_version(record), to: PaperTrail.VersionQueries 6 | defdelegate get_version(model_or_record, id_or_options), to: PaperTrail.VersionQueries 7 | defdelegate get_version(model, id, options), to: PaperTrail.VersionQueries 8 | defdelegate has_version?(record), to: PaperTrail.VersionQueries 9 | defdelegate has_version?(model_or_record, id_or_options), to: PaperTrail.VersionQueries 10 | defdelegate has_version?(model, id, options), to: PaperTrail.VersionQueries 11 | defdelegate get_versions(record), to: PaperTrail.VersionQueries 12 | defdelegate get_versions(model_or_record, id_or_options), to: PaperTrail.VersionQueries 13 | defdelegate get_versions(model, id, options), to: PaperTrail.VersionQueries 14 | defdelegate get_current_model(version), to: PaperTrail.VersionQueries 15 | defdelegate make_version_struct(version, model, options), to: Serializer 16 | defdelegate serialize(data), to: Serializer 17 | defdelegate get_sequence_id(table_name), to: Serializer 18 | defdelegate add_prefix(schema, prefix), to: Serializer 19 | defdelegate get_item_type(data), to: Serializer 20 | defdelegate get_model_id(model), to: Serializer 21 | 22 | @default_transaction_options [ 23 | origin: nil, 24 | meta: nil, 25 | originator: nil, 26 | prefix: nil, 27 | model_key: :model, 28 | version_key: :version, 29 | ecto_options: [] 30 | ] 31 | 32 | @doc """ 33 | Explicitly inserts a non-versioned already existing record into the Versions table 34 | """ 35 | def initialise( 36 | model, 37 | options \\ [origin: nil, meta: nil, originator: nil, prefix: nil, version_key: :version] 38 | ) do 39 | case has_version?(model) do 40 | false -> 41 | with {:ok, _} <- 42 | make_version_struct(%{event: "insert"}, model, options) 43 | |> PaperTrail.RepoClient.repo().insert() do 44 | :ok 45 | end 46 | 47 | _ -> 48 | # already initalised 49 | :ok 50 | end 51 | end 52 | 53 | @doc """ 54 | Inserts a record to the database with a related version insertion in one transaction 55 | """ 56 | @spec insert( 57 | changeset :: Ecto.Changeset.t(model), 58 | options :: Keyword.t() 59 | ) :: 60 | {:ok, %{model: model, version: Version.t()}} | {:error, Ecto.Changeset.t(model) | term} 61 | when model: struct 62 | def insert(changeset, options \\ @default_transaction_options) do 63 | PaperTrail.Multi.new() 64 | |> PaperTrail.Multi.insert(changeset, options) 65 | |> PaperTrail.Multi.commit() 66 | end 67 | 68 | @doc """ 69 | Same as insert/2 but returns only the model struct or raises if the changeset is invalid. 70 | """ 71 | @spec insert!(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: model 72 | when model: struct 73 | def insert!(changeset, options \\ @default_transaction_options) do 74 | changeset 75 | |> insert(options) 76 | |> model_or_error(:insert) 77 | end 78 | 79 | @doc """ 80 | Upserts a record to the database with a related version insertion in one transaction. 81 | """ 82 | @spec insert_or_update(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: 83 | {:ok, %{model: model, version: Version.t()}} | {:error, Ecto.Changeset.t(model) | term} 84 | when model: struct 85 | def insert_or_update(changeset, options \\ @default_transaction_options) do 86 | PaperTrail.Multi.new() 87 | |> PaperTrail.Multi.insert_or_update(changeset, options) 88 | |> PaperTrail.Multi.commit() 89 | end 90 | 91 | @doc """ 92 | Same as insert_or_update/2 but returns only the model struct or raises if the changeset is invalid. 93 | """ 94 | @spec insert_or_update!(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: model 95 | when model: struct 96 | def insert_or_update!(changeset, options \\ @default_transaction_options) do 97 | changeset 98 | |> insert_or_update(options) 99 | |> model_or_error(:insert_or_update) 100 | end 101 | 102 | @doc """ 103 | Updates a record from the database with a related version insertion in one transaction 104 | """ 105 | @spec update(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: 106 | {:ok, %{model: model, version: Version.t()}} | {:error, Ecto.Changeset.t(model) | term} 107 | when model: struct 108 | def update(changeset, options \\ @default_transaction_options) do 109 | PaperTrail.Multi.new() 110 | |> PaperTrail.Multi.update(changeset, options) 111 | |> PaperTrail.Multi.commit() 112 | end 113 | 114 | @doc """ 115 | Same as update/2 but returns only the model struct or raises if the changeset is invalid. 116 | """ 117 | @spec update!(changeset :: Ecto.Changeset.t(model), options :: Keyword.t()) :: model 118 | when model: struct 119 | def update!(changeset, options \\ @default_transaction_options) do 120 | changeset 121 | |> update(options) 122 | |> model_or_error(:update) 123 | end 124 | 125 | @doc """ 126 | Deletes a record from the database with a related version insertion in one transaction 127 | """ 128 | @spec delete(model_or_changeset :: model | Ecto.Changeset.t(model), options :: Keyword.t()) :: 129 | {:ok, %{model: model, version: Version.t()}} | {:error, Ecto.Changeset.t(model) | term} 130 | when model: struct 131 | def delete(model_or_changeset, options \\ @default_transaction_options) do 132 | PaperTrail.Multi.new() 133 | |> PaperTrail.Multi.delete(model_or_changeset, options) 134 | |> PaperTrail.Multi.commit() 135 | end 136 | 137 | @doc """ 138 | Same as delete/2 but returns only the model struct or raises if the changeset is invalid. 139 | """ 140 | @spec delete!(model_or_changeset :: model | Ecto.Changeset.t(model), options :: Keyword.t()) :: 141 | model 142 | when model: struct 143 | def delete!(model_or_changeset, options \\ @default_transaction_options) do 144 | model_or_changeset 145 | |> delete(options) 146 | |> model_or_error(:delete) 147 | end 148 | 149 | @spec model_or_error( 150 | result :: {:ok, %{required(:model) => model, optional(any()) => any()}}, 151 | action :: :insert | :insert_or_update | :update | :delete 152 | ) :: 153 | model 154 | when model: struct() 155 | defp model_or_error({:ok, %{model: model}}, _action) do 156 | model 157 | end 158 | 159 | @spec model_or_error( 160 | result :: {:error, reason :: term}, 161 | action :: :insert | :insert_or_update | :update | :delete 162 | ) :: no_return 163 | defp model_or_error({:error, %Ecto.Changeset{} = changeset}, action) do 164 | raise Ecto.InvalidChangesetError, action: action, changeset: changeset 165 | end 166 | 167 | defp model_or_error({:error, reason}, _action) do 168 | raise reason 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /lib/paper_trail/multi.ex: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.Multi do 2 | import Ecto.Changeset 3 | 4 | alias PaperTrail 5 | alias PaperTrail.Version 6 | alias PaperTrail.RepoClient 7 | alias PaperTrail.Serializer 8 | 9 | defdelegate new(), to: Ecto.Multi 10 | defdelegate append(lhs, rhs), to: Ecto.Multi 11 | defdelegate error(multi, name, value), to: Ecto.Multi 12 | defdelegate merge(multi, merge), to: Ecto.Multi 13 | defdelegate merge(multi, mod, fun, args), to: Ecto.Multi 14 | defdelegate prepend(lhs, rhs), to: Ecto.Multi 15 | defdelegate run(multi, name, run), to: Ecto.Multi 16 | defdelegate run(multi, name, mod, fun, args), to: Ecto.Multi 17 | defdelegate to_list(multi), to: Ecto.Multi 18 | defdelegate make_version_struct(version, model, options), to: Serializer 19 | defdelegate serialize(data), to: Serializer 20 | defdelegate get_sequence_id(table_name), to: Serializer 21 | defdelegate add_prefix(schema, prefix), to: Serializer 22 | defdelegate get_item_type(data), to: Serializer 23 | defdelegate get_model_id(model), to: Serializer 24 | 25 | @default_transaction_options [ 26 | origin: nil, 27 | meta: nil, 28 | originator: nil, 29 | prefix: nil, 30 | model_key: :model, 31 | version_key: :version, 32 | initial_version_key: :initial_version, 33 | ecto_options: [] 34 | ] 35 | 36 | def insert(%Ecto.Multi{} = multi, changeset, options \\ @default_transaction_options) do 37 | model_key = options[:model_key] || :model 38 | version_key = options[:version_key] || :version 39 | initial_version_key = options[:initial_version_key] || :initial_version 40 | ecto_options = options[:ecto_options] || [] 41 | 42 | case RepoClient.strict_mode() do 43 | true -> 44 | multi 45 | |> Ecto.Multi.run(initial_version_key, fn repo, %{} -> 46 | version_id = get_sequence_id("versions") + 1 47 | 48 | changeset_data = 49 | Map.get(changeset, :data, changeset) 50 | |> Map.merge(%{ 51 | id: get_sequence_id(changeset) + 1, 52 | first_version_id: version_id, 53 | current_version_id: version_id 54 | }) 55 | 56 | initial_version = make_version_struct(%{event: "insert"}, changeset_data, options) 57 | repo.insert(initial_version) 58 | end) 59 | |> Ecto.Multi.run(model_key, fn repo, %{^initial_version_key => initial_version} -> 60 | updated_changeset = 61 | changeset 62 | |> change(%{ 63 | first_version_id: initial_version.id, 64 | current_version_id: initial_version.id 65 | }) 66 | 67 | repo.insert(updated_changeset, ecto_options) 68 | end) 69 | |> Ecto.Multi.run(version_key, fn repo, 70 | %{ 71 | ^initial_version_key => initial_version, 72 | ^model_key => model 73 | } -> 74 | target_version = make_version_struct(%{event: "insert"}, model, options) |> serialize() 75 | 76 | Version.changeset(initial_version, target_version) |> repo.update 77 | end) 78 | 79 | _ -> 80 | multi 81 | |> Ecto.Multi.insert(model_key, changeset, ecto_options) 82 | |> Ecto.Multi.run(version_key, fn repo, %{^model_key => model} -> 83 | version = make_version_struct(%{event: "insert"}, model, options) 84 | repo.insert(version) 85 | end) 86 | end 87 | end 88 | 89 | def update(multi, changeset, options \\ @default_transaction_options) 90 | 91 | def update(%Ecto.Multi{} = multi, %Ecto.Changeset{changes: changes} = changeset, options) 92 | when changes == %{} do 93 | # when there's no changes to save, rely on ecto's update being a no-op 94 | model_key = options[:model_key] || :model 95 | ecto_options = options[:ecto_options] || [] 96 | 97 | multi 98 | |> Ecto.Multi.update( 99 | model_key, 100 | changeset, 101 | ecto_options ++ Keyword.take(options, [:returning]) 102 | ) 103 | end 104 | 105 | def update(%Ecto.Multi{} = multi, changeset, options) do 106 | model_key = options[:model_key] || :model 107 | version_key = options[:version_key] || :version 108 | initial_version_key = options[:initial_version_key] || :initial_version 109 | ecto_options = options[:ecto_options] || [] 110 | 111 | case RepoClient.strict_mode() do 112 | true -> 113 | multi 114 | |> Ecto.Multi.run(initial_version_key, fn repo, %{} -> 115 | version_data = 116 | changeset.data 117 | |> Map.merge(%{ 118 | current_version_id: get_sequence_id("versions") 119 | }) 120 | 121 | target_changeset = changeset |> Map.merge(%{data: version_data}) 122 | target_version = make_version_struct(%{event: "update"}, target_changeset, options) 123 | repo.insert(target_version) 124 | end) 125 | |> Ecto.Multi.run(model_key, fn repo, %{^initial_version_key => initial_version} -> 126 | updated_changeset = changeset |> change(%{current_version_id: initial_version.id}) 127 | repo.update(updated_changeset, Keyword.take(options, [:returning])) 128 | end) 129 | |> Ecto.Multi.run(version_key, fn repo, %{^initial_version_key => initial_version} -> 130 | new_item_changes = 131 | initial_version.item_changes 132 | |> Map.merge(%{ 133 | current_version_id: initial_version.id 134 | }) 135 | 136 | initial_version |> change(%{item_changes: new_item_changes}) |> repo.update 137 | end) 138 | 139 | _ -> 140 | multi 141 | |> Ecto.Multi.update( 142 | model_key, 143 | changeset, 144 | ecto_options ++ Keyword.take(options, [:returning]) 145 | ) 146 | |> Ecto.Multi.run(version_key, fn repo, %{^model_key => _model} -> 147 | version = make_version_struct(%{event: "update"}, changeset, options) 148 | repo.insert(version) 149 | end) 150 | end 151 | end 152 | 153 | def insert_or_update(%Ecto.Multi{} = multi, changeset, options \\ @default_transaction_options) do 154 | case get_state(changeset) do 155 | :built -> 156 | insert(multi, changeset, options) 157 | 158 | :loaded -> 159 | update(multi, changeset, options) 160 | 161 | state -> 162 | raise ArgumentError, 163 | "the changeset has an invalid state " <> 164 | "for PaperTrail.insert_or_update/2 or PaperTrail.insert_or_update!/2: #{state}" 165 | end 166 | end 167 | 168 | def delete(%Ecto.Multi{} = multi, struct, options \\ @default_transaction_options) do 169 | model_key = options[:model_key] || :model 170 | version_key = options[:version_key] || :version 171 | ecto_options = options[:ecto_options] || [] 172 | 173 | multi 174 | |> Ecto.Multi.delete(model_key, struct, ecto_options) 175 | |> Ecto.Multi.run(version_key, fn repo, %{} -> 176 | version = make_version_struct(%{event: "delete"}, struct, options) 177 | repo.insert(version, options) 178 | end) 179 | end 180 | 181 | def commit(%Ecto.Multi{} = multi) do 182 | repo = RepoClient.repo() 183 | 184 | transaction = repo.transaction(multi) 185 | 186 | case RepoClient.strict_mode() do 187 | true -> 188 | case transaction do 189 | {:error, _, changeset, %{}} -> 190 | filtered_changes = 191 | Map.drop(changeset.changes, [:current_version_id, :first_version_id]) 192 | 193 | {:error, Map.merge(changeset, %{repo: repo, changes: filtered_changes})} 194 | 195 | {:ok, map} -> 196 | {:ok, Map.drop(map, [:initial_version])} 197 | end 198 | 199 | _ -> 200 | case transaction do 201 | {:error, _, changeset, %{}} -> {:error, Map.merge(changeset, %{repo: repo})} 202 | _ -> transaction 203 | end 204 | end 205 | end 206 | 207 | defp get_state(%Ecto.Changeset{data: %{__meta__: %{state: state}}}), do: state 208 | 209 | defp get_state(%{__struct__: _}) do 210 | raise ArgumentError, 211 | "giving a struct to PaperTrail.insert_or_update/2 or " <> 212 | "PaperTrail.insert_or_update!/2 is not supported. " <> 213 | "Please use an Ecto.Changeset" 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /lib/paper_trail/repo_client.ex: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.RepoClient do 2 | @doc """ 3 | Gets the configured repo module or defaults to Repo if none configured 4 | """ 5 | def repo, do: env(:repo, Repo) 6 | def originator, do: env(:originator, nil) 7 | def strict_mode, do: env(:strict_mode, false) 8 | def item_type, do: env(:item_type, :integer) 9 | def originator_type, do: env(:originator_type, :integer) 10 | def originator_relationship_opts, do: env(:originator_relationship_options, []) 11 | def timestamps_type, do: env(:timestamps_type, :utc_datetime) 12 | def origin_read_after_writes(), do: env(:origin_read_after_writes, true) 13 | 14 | defp env(k, default), do: Application.get_env(:paper_trail, k, default) 15 | end 16 | -------------------------------------------------------------------------------- /lib/paper_trail/serializer.ex: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.Serializer do 2 | @moduledoc """ 3 | Serialization functions to create a version struct 4 | """ 5 | 6 | alias PaperTrail.RepoClient 7 | alias PaperTrail.Version 8 | 9 | @type model :: struct() | Ecto.Changeset.t() 10 | @type options :: Keyword.t() 11 | @type primary_key :: integer() | String.t() 12 | 13 | @doc """ 14 | Creates a version struct for a model and a specific changeset action 15 | """ 16 | @spec make_version_struct(map(), model(), options()) :: Version.t() 17 | def make_version_struct(%{event: "insert"}, model, options) do 18 | originator = RepoClient.originator() 19 | originator_ref = options[originator[:name]] || options[:originator] 20 | 21 | %Version{ 22 | event: "insert", 23 | item_type: get_item_type(model), 24 | item_id: get_model_id(model), 25 | item_changes: serialize(model), 26 | originator_id: 27 | case originator_ref do 28 | nil -> nil 29 | %{id: id} -> id 30 | model when is_struct(model) -> get_model_id(originator_ref) 31 | end, 32 | origin: options[:origin], 33 | meta: options[:meta] 34 | } 35 | |> add_prefix(options[:prefix]) 36 | end 37 | 38 | def make_version_struct(%{event: "update"}, changeset, options) do 39 | originator = RepoClient.originator() 40 | originator_ref = options[originator[:name]] || options[:originator] 41 | 42 | %Version{ 43 | event: "update", 44 | item_type: get_item_type(changeset), 45 | item_id: get_model_id(changeset), 46 | item_changes: serialize_changes(changeset), 47 | originator_id: 48 | case originator_ref do 49 | nil -> nil 50 | %{id: id} -> id 51 | model when is_struct(model) -> get_model_id(originator_ref) 52 | end, 53 | origin: options[:origin], 54 | meta: options[:meta] 55 | } 56 | |> add_prefix(options[:prefix]) 57 | end 58 | 59 | def make_version_struct(%{event: "delete"}, model_or_changeset, options) do 60 | originator = RepoClient.originator() 61 | originator_ref = options[originator[:name]] || options[:originator] 62 | 63 | %Version{ 64 | event: "delete", 65 | item_type: get_item_type(model_or_changeset), 66 | item_id: get_model_id(model_or_changeset), 67 | item_changes: serialize(model_or_changeset), 68 | originator_id: 69 | case originator_ref do 70 | nil -> nil 71 | %{id: id} -> id 72 | model when is_struct(model) -> get_model_id(originator_ref) 73 | end, 74 | origin: options[:origin], 75 | meta: options[:meta] 76 | } 77 | |> add_prefix(options[:prefix]) 78 | end 79 | 80 | @doc """ 81 | Returns the last primary key value of a table 82 | """ 83 | @spec get_sequence_id(model() | String.t()) :: primary_key() 84 | def get_sequence_id(%Ecto.Changeset{data: data}) do 85 | get_sequence_id(data) 86 | end 87 | 88 | def get_sequence_id(%schema{}) do 89 | :source 90 | |> schema.__schema__() 91 | |> get_sequence_id() 92 | end 93 | 94 | def get_sequence_id(table_name) when is_binary(table_name) do 95 | Ecto.Adapters.SQL.query!(RepoClient.repo(), "select last_value FROM #{table_name}_id_seq").rows 96 | |> List.first() 97 | |> List.first() 98 | end 99 | 100 | @doc """ 101 | Shows DB representation of an Ecto model, filters relationships and virtual attributes from an Ecto.Changeset or %ModelStruct{} 102 | """ 103 | @spec serialize(nil | Ecto.Changeset.t() | struct()) :: nil | map() 104 | def serialize(nil), do: nil 105 | def serialize(%Ecto.Changeset{data: data}), do: serialize(data) 106 | def serialize(%_schema{} = model), do: Ecto.embedded_dump(model, :json) 107 | 108 | @doc """ 109 | Dumps changes using Ecto fields 110 | """ 111 | @spec serialize_changes(Ecto.Changeset.t()) :: map() 112 | def serialize_changes(%Ecto.Changeset{changes: changes} = changeset) do 113 | changeset 114 | |> serialize_model_changes() 115 | |> serialize() 116 | |> Map.take(Map.keys(changes)) 117 | end 118 | 119 | @doc """ 120 | Adds a prefix to the Ecto schema 121 | """ 122 | @spec add_prefix(Ecto.Schema.schema(), nil | String.t()) :: Ecto.Schema.schema() 123 | def add_prefix(schema, nil), do: schema 124 | def add_prefix(schema, prefix), do: Ecto.put_meta(schema, prefix: prefix) 125 | 126 | @doc """ 127 | Returns the model type, which is the last module name 128 | """ 129 | @spec get_item_type(model()) :: String.t() 130 | def get_item_type(%Ecto.Changeset{data: data}), do: get_item_type(data) 131 | def get_item_type(%schema{}), do: schema |> Module.split() |> List.last() 132 | 133 | @doc """ 134 | Returns the model primary id 135 | """ 136 | @spec get_model_id(model()) :: primary_key() 137 | def get_model_id(%Ecto.Changeset{data: data}), do: get_model_id(data) 138 | 139 | def get_model_id(model) do 140 | {_, model_id} = List.first(Ecto.primary_key(model)) 141 | 142 | case PaperTrail.Version.__schema__(:type, :item_id) do 143 | :integer -> 144 | model_id 145 | 146 | _ -> 147 | "#{model_id}" 148 | end 149 | end 150 | 151 | @spec serialize_model_changes(nil) :: nil 152 | defp serialize_model_changes(nil), do: nil 153 | 154 | @spec serialize_model_changes(Ecto.Changeset.t()) :: map() 155 | defp serialize_model_changes(%Ecto.Changeset{data: %schema{}} = changeset) do 156 | field_values = serialize_model_field_changes(changeset) 157 | embed_values = serialize_model_embed_changes(changeset) 158 | 159 | field_values 160 | |> Map.merge(embed_values) 161 | |> schema.__struct__() 162 | end 163 | 164 | defp serialize_model_field_changes(%Ecto.Changeset{data: %schema{}, changes: changes}) do 165 | change_keys = changes |> Map.keys() |> MapSet.new() 166 | 167 | field_keys = 168 | :fields 169 | |> schema.__schema__() 170 | |> MapSet.new() 171 | |> MapSet.intersection(change_keys) 172 | |> MapSet.to_list() 173 | 174 | Map.take(changes, field_keys) 175 | end 176 | 177 | defp serialize_model_embed_changes(%Ecto.Changeset{data: %schema{}, changes: changes}) do 178 | change_keys = changes |> Map.keys() |> MapSet.new() 179 | 180 | embed_keys = 181 | :embeds 182 | |> schema.__schema__() 183 | |> MapSet.new() 184 | |> MapSet.intersection(change_keys) 185 | |> MapSet.to_list() 186 | 187 | changes 188 | |> Map.take(embed_keys) 189 | |> Map.new(fn {key, value} -> 190 | case schema.__schema__(:embed, key) do 191 | %Ecto.Embedded{cardinality: :one} -> {key, serialize_model_changes(value)} 192 | %Ecto.Embedded{cardinality: :many} -> {key, Enum.map(value, &serialize_model_changes/1)} 193 | end 194 | end) 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /lib/paper_trail/version_queries.ex: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.VersionQueries do 2 | import Ecto.Query 3 | alias PaperTrail.Version 4 | 5 | @doc """ 6 | Gets all the versions of a record. 7 | 8 | A list of options is optional, so you can set for example the :prefix of the query, 9 | wich allows you to change between different tenants. 10 | 11 | # Usage examples: 12 | 13 | iex(1)> PaperTrail.VersionQueries.get_versions(record) 14 | iex(1)> PaperTrail.VersionQueries.get_versions(record, [prefix: "tenant_id"]) 15 | iex(1)> PaperTrail.VersionQueries.get_versions(ModelName, id) 16 | iex(1)> PaperTrail.VersionQueries.get_versions(ModelName, id, [prefix: "tenant_id"]) 17 | """ 18 | @spec get_versions(record :: Ecto.Schema.t()) :: [Version.t()] 19 | def get_versions(record), do: get_versions(record, []) 20 | 21 | @doc """ 22 | Gets all the versions of a record given a module and its id 23 | """ 24 | @spec get_versions(model :: module, id :: pos_integer) :: [Version.t()] 25 | def get_versions(model, id) when is_atom(model) and (is_integer(id) or is_binary(id)), 26 | do: get_versions(model, id, []) 27 | 28 | @spec get_versions(record :: Ecto.Schema.t(), options :: keyword | []) :: [Version.t()] 29 | def get_versions(record, options) when is_map(record) do 30 | item_type = record.__struct__ |> Module.split() |> List.last() 31 | 32 | version_query(item_type, PaperTrail.get_model_id(record), options) 33 | |> PaperTrail.RepoClient.repo().all 34 | end 35 | 36 | @spec get_versions(model :: module, id :: pos_integer, options :: keyword | []) :: [Version.t()] 37 | def get_versions(model, id, options) do 38 | item_type = model |> Module.split() |> List.last() 39 | version_query(item_type, id, options) |> PaperTrail.RepoClient.repo().all 40 | end 41 | 42 | @doc """ 43 | Gets the last version of a record. 44 | 45 | A list of options is optional, so you can set for example the :prefix of the query, 46 | wich allows you to change between different tenants. 47 | 48 | # Usage examples: 49 | 50 | iex(1)> PaperTrail.VersionQueries.get_version(record, id) 51 | iex(1)> PaperTrail.VersionQueries.get_version(record, [prefix: "tenant_id"]) 52 | iex(1)> PaperTrail.VersionQueries.get_version(ModelName, id) 53 | iex(1)> PaperTrail.VersionQueries.get_version(ModelName, id, [prefix: "tenant_id"]) 54 | """ 55 | @spec get_version(record :: Ecto.Schema.t()) :: Version.t() | nil 56 | def get_version(record), do: get_version(record, []) 57 | 58 | @spec get_version(model :: module, id :: pos_integer) :: Version.t() | nil 59 | def get_version(model, id) when is_atom(model) and (is_integer(id) or is_binary(id)), 60 | do: get_version(model, id, []) 61 | 62 | @spec get_version(record :: Ecto.Schema.t(), options :: keyword | []) :: Version.t() | nil 63 | def get_version(record, options) when is_map(record) do 64 | get_version(record.__struct__, PaperTrail.get_model_id(record), options) 65 | end 66 | 67 | @spec get_version(model :: module, id :: pos_integer, options :: keyword | []) :: 68 | Version.t() | nil 69 | def get_version(model, id, options) do 70 | last(version_query(model, id, options)) 71 | |> PaperTrail.RepoClient.repo().one 72 | end 73 | 74 | @spec has_version?(record :: Ecto.Schema.t()) :: boolean 75 | def has_version?(record), do: has_version?(record, []) 76 | 77 | @spec has_version?(model :: module, id :: pos_integer) :: boolean 78 | def has_version?(model, id) when is_atom(model) and (is_integer(id) or is_binary(id)), 79 | do: has_version?(model, id, []) 80 | 81 | @spec has_version?(record :: Ecto.Schema.t(), options :: keyword | []) :: boolean 82 | def has_version?(record, options) when is_map(record) do 83 | has_version?(record.__struct__, PaperTrail.get_model_id(record), options) 84 | end 85 | 86 | @spec has_version?(model :: module, id :: pos_integer, options :: keyword | []) :: boolean 87 | def has_version?(model, id, options) do 88 | version_query(model, id, options) 89 | |> PaperTrail.RepoClient.repo().exists?() 90 | end 91 | 92 | @doc """ 93 | Gets the current model record/struct of a version 94 | """ 95 | @spec get_current_model(version :: Version.t()) :: Ecto.Schema.t() | nil 96 | def get_current_model(version) do 97 | PaperTrail.RepoClient.repo().get( 98 | ("Elixir." <> version.item_type) |> String.to_existing_atom(), 99 | version.item_id 100 | ) 101 | end 102 | 103 | defp version_query(item_type, id) do 104 | from(v in Version, where: v.item_type == ^item_type and v.item_id == ^id) 105 | end 106 | 107 | defp version_query(model, id, options) when is_atom(model) do 108 | model 109 | |> Module.split() 110 | |> List.last() 111 | |> version_query(id, options) 112 | end 113 | 114 | defp version_query(item_type, id, options) when is_binary(item_type) do 115 | with opts <- Enum.into(options, %{}) do 116 | version_query(item_type, id) 117 | |> Ecto.Queryable.to_query() 118 | |> Map.merge(opts) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/version.ex: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.Version do 2 | use Ecto.Schema 3 | 4 | import Ecto.Changeset 5 | import Ecto.Query 6 | 7 | @type t :: %__MODULE__{} 8 | 9 | alias PaperTrail.RepoClient 10 | 11 | # @setter RepoClient.originator() 12 | # @item_type Application.get_env(:paper_trail, :item_type, :integer) 13 | # @originator_type Application.get_env(:paper_trail, :originator_type, :integer) 14 | 15 | schema "versions" do 16 | field(:event, :string) 17 | field(:item_type, :string) 18 | field(:item_id, RepoClient.item_type()) 19 | field(:item_changes, :map) 20 | field(:originator_id, RepoClient.originator_type()) 21 | 22 | field(:origin, :string, read_after_writes: RepoClient.origin_read_after_writes()) 23 | 24 | field(:meta, :map) 25 | 26 | if RepoClient.originator() do 27 | belongs_to( 28 | RepoClient.originator()[:name], 29 | RepoClient.originator()[:model], 30 | Keyword.merge(RepoClient.originator_relationship_opts(), 31 | define_field: false, 32 | foreign_key: :originator_id, 33 | type: RepoClient.originator_type() 34 | ) 35 | ) 36 | end 37 | 38 | timestamps( 39 | updated_at: false, 40 | type: RepoClient.timestamps_type() 41 | ) 42 | end 43 | 44 | def changeset(model, params \\ %{}) do 45 | model 46 | |> cast(params, [:item_type, :item_id, :item_changes, :origin, :originator_id, :meta]) 47 | |> validate_required([:event, :item_type, :item_id, :item_changes]) 48 | end 49 | 50 | @doc """ 51 | Returns the count of all version records in the database 52 | """ 53 | def count do 54 | from(version in __MODULE__, select: count(version.id)) |> RepoClient.repo().one() 55 | end 56 | 57 | def count(options) do 58 | from(version in __MODULE__, select: count(version.id)) 59 | |> Ecto.Queryable.to_query() 60 | |> Map.put(:prefix, options[:prefix]) 61 | |> RepoClient.repo().one 62 | end 63 | 64 | @doc """ 65 | Returns the first version record in the database by :inserted_at 66 | """ 67 | def first do 68 | from(record in __MODULE__, limit: 1, order_by: [asc: :inserted_at]) 69 | |> RepoClient.repo().one 70 | end 71 | 72 | def first(options) do 73 | from(record in __MODULE__, limit: 1, order_by: [asc: :inserted_at]) 74 | |> Ecto.Queryable.to_query() 75 | |> Map.put(:prefix, options[:prefix]) 76 | |> RepoClient.repo().one 77 | end 78 | 79 | @doc """ 80 | Returns the last version record in the database by :inserted_at 81 | """ 82 | def last do 83 | from(record in __MODULE__, limit: 1, order_by: [desc: :inserted_at]) 84 | |> RepoClient.repo().one 85 | end 86 | 87 | def last(options) do 88 | from(record in __MODULE__, limit: 1, order_by: [desc: :inserted_at]) 89 | |> Ecto.Queryable.to_query() 90 | |> Map.put(:prefix, options[:prefix]) 91 | |> RepoClient.repo().one 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.MixProject do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/izelnakri/paper_trail" 5 | @version "1.1.2" 6 | 7 | def project do 8 | [ 9 | app: :paper_trail, 10 | version: @version, 11 | elixir: "~> 1.17", 12 | description: description(), 13 | build_embedded: Mix.env() == :prod, 14 | start_permanent: Mix.env() == :prod, 15 | elixirc_paths: elixirc_paths(Mix.env()), 16 | package: package(), 17 | deps: deps(), 18 | docs: docs() 19 | ] 20 | end 21 | 22 | def application do 23 | [ 24 | extra_applications: [:logger, :ecto, :ecto_sql, :runtime_tools] 25 | ] 26 | end 27 | 28 | defp deps do 29 | [ 30 | {:ecto, ">= 3.12.2"}, 31 | {:ecto_sql, ">= 3.12.0"}, 32 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 33 | {:jason, ">= 1.4.4", only: [:dev, :test]}, 34 | {:postgrex, ">= 0.0.0", only: [:dev, :test]} 35 | ] 36 | end 37 | 38 | defp description do 39 | """ 40 | Track and record all the changes in your database. Revert back to anytime 41 | in history. 42 | """ 43 | end 44 | 45 | defp package do 46 | [ 47 | name: :paper_trail, 48 | files: ["lib", "mix.exs", "README*", "LICENSE*", "CHANGELOG*"], 49 | maintainers: ["Izel Nakri"], 50 | licenses: ["MIT License"], 51 | links: %{ 52 | "Changelog" => "#{@source_url}/blob/master/CHANGELOG.md", 53 | "GitHub" => @source_url 54 | } 55 | ] 56 | end 57 | 58 | defp docs do 59 | [ 60 | main: "readme", 61 | source_ref: "v#{@version}", 62 | source_url: @source_url, 63 | extras: [ 64 | "README.md", 65 | "CHANGELOG.md" 66 | ] 67 | ] 68 | end 69 | 70 | defp elixirc_paths(:test), do: ["lib", "test/support"] 71 | defp elixirc_paths(_), do: ["lib"] 72 | end 73 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 3 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 4 | "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, 6 | "ecto": {:hex, :ecto, "3.12.2", "bae2094f038e9664ce5f089e5f3b6132a535d8b018bd280a485c2f33df5c0ce1", [: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", "492e67c70f3a71c6afe80d946d3ced52ecc57c53c9829791bfff1830ff5a1f0c"}, 7 | "ecto_sql": {:hex, :ecto_sql, "3.12.0", "73cea17edfa54bde76ee8561b30d29ea08f630959685006d9c6e7d1e59113b7d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 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", "dc9e4d206f274f3947e96142a8fdc5f69a2a6a9abb4649ef5c882323b6d512f0"}, 8 | "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [: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", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, 9 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 10 | "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"}, 11 | "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"}, 12 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 13 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 14 | "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [: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", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"}, 15 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "paper_trail", 4 | "author": "Izel Nakri", 5 | "version": "1.1.2", 6 | "description": "[![Hex Version](http://img.shields.io/hexpm/v/paper_trail.svg?style=flat)](https://hex.pm/packages/paper_trail) [![Hex docs](http://img.shields.io/badge/hex.pm-docs-green.svg?style=flat)](https://hexdocs.pm/paper_trail/PaperTrail.html) [![Total Download](https://img.shields.io/hexpm/dt/paper_trail.svg)](https://hex.pm/packages/paper_trail) [![License](https://img.shields.io/hexpm/l/paper_trail.svg)](https://github.com/izelnakri/paper_trail/blob/main/LICENSE) [![Last Updated](https://img.shields.io/github/last-commit/izelnakri/paper_trail.svg)](https://github.com/izelnakri/paper_trail/commits/main)", 7 | "main": "index.js", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/izelnakri/paper_trail.git" 11 | }, 12 | "scripts": { 13 | "changelog:unreleased": "node_modules/.bin/auto-changelog --stdout --commit-limit false --package --unreleased-only --hide-credit --sort-commits date-desc", 14 | "changelog:preview": "node_modules/.bin/auto-changelog --stdout --commit-limit false --package -u --sort-commits date-desc", 15 | "changelog:update": "node_modules/.bin/auto-changelog --commit-limit false --package --sort-commits date-desc", 16 | "release:alpha": "node_modules/.bin/release-it --preRelease=alpha --no-npm.publish && MIX_ENV=dev mix hex.publish --yes", 17 | "release:beta": "node_modules/.bin/release-it --preRelease=beta --no-npm.publish && MIX_ENV=dev mix hex.publish --yes", 18 | "release": "node_modules/.bin/release-it --no-npm.publish && MIX_ENV=dev mix hex.publish --yes", 19 | "test": "sh setup-database.sh && mix test test/paper_trail && mix test test/version && mix test test/uuid && STRING_TEST=true mix test test/uuid" 20 | }, 21 | "license": "MIT", 22 | "release-it": { 23 | "plugins": { 24 | "@j-ulrich/release-it-regex-bumper": { 25 | "in": "package.json", 26 | "out": { 27 | "file": "mix.exs", 28 | "search": { 29 | "pattern": "@version \"([0-9.]+)\"" 30 | }, 31 | "replace": "@version \"{{version}}\"" 32 | } 33 | } 34 | }, 35 | "git": { 36 | "changelog": "npm run changelog:preview" 37 | }, 38 | "github": { 39 | "release": true 40 | }, 41 | "hooks": { 42 | "after:bump": "npm run changelog:update" 43 | } 44 | }, 45 | "devDependencies": { 46 | "@j-ulrich/release-it-regex-bumper": "^5.1.0", 47 | "auto-changelog": "^2.4.0", 48 | "release-it": "^17.6.0" 49 | }, 50 | "volta": { 51 | "node": "20.17.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160619190935_add_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.AddUsers do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :token, :string, null: false 7 | add :username, :string, null: false 8 | 9 | timestamps() 10 | end 11 | 12 | create index(:users, [:token]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160619190936_add_versions.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.AddVersions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:versions) do 6 | add :event, :string, null: false 7 | add :item_type, :string, null: false 8 | add :item_id, :integer, null: false 9 | add :item_changes, :map, null: false 10 | add :originator_id, references(:users) # you can change users to your own foreign key constraint 11 | add :origin, :string, size: 50 12 | add :meta, :map 13 | 14 | add :inserted_at, :utc_datetime, null: false 15 | end 16 | 17 | create index(:versions, [:originator_id]) 18 | create index(:versions, [:item_type, :item_id]) 19 | create index(:versions, [:item_type, :inserted_at]) 20 | # create index(:versions, [:event, :item_type]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160619190937_add_simple_companies.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.CreateSimpleCompanies do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:simple_companies) do 6 | add :name, :string, null: false 7 | add :is_active, :boolean 8 | add :website, :string 9 | add :city, :string 10 | add :address, :string 11 | add :facebook, :string 12 | add :twitter, :string 13 | add :founded_in, :string 14 | add :location, :map 15 | 16 | timestamps() 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /priv/repo/migrations/20160619190938_add_simple_people.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.CreateSimplePeople do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:simple_people) do 6 | add :first_name, :string, null: false 7 | add :last_name, :string 8 | add :visit_count, :integer 9 | add :gender, :boolean 10 | add :birthdate, :date 11 | add :singular, :map 12 | add :plural, {:array, :map} 13 | 14 | add :company_id, references(:simple_companies), null: false 15 | 16 | timestamps() 17 | end 18 | 19 | create index(:simple_people, [:company_id]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170319190938_add_strict_companies.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.CreateStrictCompanies do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:strict_companies) do 6 | add :name, :string, null: false 7 | add :is_active, :boolean 8 | add :website, :string 9 | add :city, :string 10 | add :address, :string 11 | add :facebook, :string 12 | add :twitter, :string 13 | add :founded_in, :string 14 | 15 | add :first_version_id, references(:versions), null: false 16 | add :current_version_id, references(:versions), null: false 17 | 18 | timestamps() 19 | end 20 | 21 | create index(:strict_companies, [:first_version_id]) 22 | create index(:strict_companies, [:current_version_id]) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170319190940_add_strict_people.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.CreateStrictPeople do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:strict_people) do 6 | add :first_name, :string, null: false 7 | add :last_name, :string 8 | add :visit_count, :integer 9 | add :gender, :boolean 10 | add :birthdate, :date 11 | 12 | add :company_id, references(:strict_companies), null: false 13 | add :first_version_id, references(:versions), null: false 14 | add :current_version_id, references(:versions), null: false 15 | 16 | timestamps() 17 | end 18 | 19 | create index(:strict_people, [:company_id]) 20 | create index(:strict_people, [:first_version_id]) 21 | create index(:strict_people, [:current_version_id]) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /priv/repo/migrations/20200827222744_add_uniqueness_constraint_to_companies_name.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.Repo.Migrations.AddUniquenessConstraintToCompaniesName do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create unique_index(:simple_companies, [:name]) 6 | create unique_index(:strict_companies, [:name]) 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /priv/uuid_repo/migrations/20170525133833_create_uuid_products.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.UUIDRepo.Migrations.CreateUuidProducts do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:products, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :name, :string, null: false 8 | 9 | timestamps() 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/uuid_repo/migrations/20170525142546_create_admins.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.UUIDRepo.Migrations.CreateAdmins do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:admins, primary_key: false) do 6 | add :id, :binary_id, primary_key: true 7 | add :email, :string, null: false 8 | 9 | timestamps() 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/uuid_repo/migrations/20170525142612_create_versions.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.UUIDRepo.Migrations.CreateVersions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:versions) do 6 | add :event, :string, null: false, size: 10 7 | add :item_type, :string, null: false 8 | add :item_id, (if System.get_env("STRING_TEST") == nil, do: :binary_id, else: :string) 9 | add :item_changes, :map, null: false 10 | add :originator_id, references(:admins, type: :binary_id) 11 | add :origin, :string, size: 50 12 | add :meta, :map 13 | 14 | add :inserted_at, :utc_datetime, null: false 15 | end 16 | 17 | create index(:versions, [:originator_id]) 18 | create index(:versions, [:item_id, :item_type]) 19 | create index(:versions, [:event, :item_type]) 20 | create index(:versions, [:item_type, :inserted_at]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /priv/uuid_repo/migrations/20170525142613_create_items.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.UUIDRepo.Migrations.CreateItems do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:items) do 6 | add :item_id, :binary_id, null: false, primary_key: true 7 | add :title, :string, null: false 8 | 9 | timestamps() 10 | end 11 | 12 | create table(:foo_items) do 13 | add :title, :string, null: false 14 | 15 | timestamps() 16 | end 17 | 18 | create table(:bar_items, primary_key: false) do 19 | add :item_id, :string, primary_key: true 20 | add :title, :string, null: false 21 | 22 | timestamps() 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /priv/uuid_with_custom_name_repo/migrations/20201130190530_create_projects.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.UUIDWithCustomNameRepo.Migrations.CreateProjects do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:projects, primary_key: false) do 6 | add :uuid, :binary_id, primary_key: true 7 | add :name, :string, null: false 8 | 9 | timestamps() 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/uuid_with_custom_name_repo/migrations/20201130190545_create_people.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.UUIDWithCustomNameRepo.Migrations.CreatePeople do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:people, primary_key: false) do 6 | add :uuid, :binary_id, primary_key: true 7 | add :email, :string, null: false 8 | 9 | timestamps() 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/uuid_with_custom_name_repo/migrations/20201130190555_create_versions.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.UUIDWithCustomNameRepo.Migrations.CreateVersions do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:versions) do 6 | add :event, :string, null: false, size: 10 7 | add :item_type, :string, null: false 8 | add :item_id, (if System.get_env("STRING_TEST") == nil, do: :binary_id, else: :string) 9 | add :item_changes, :map, null: false 10 | add :originator_id, references(:people, type: :binary_id, column: :uuid) 11 | add :origin, :string, size: 50 12 | add :meta, :map 13 | 14 | add :inserted_at, :utc_datetime, null: false 15 | end 16 | 17 | create index(:versions, [:originator_id]) 18 | create index(:versions, [:item_id, :item_type]) 19 | create index(:versions, [:event, :item_type]) 20 | create index(:versions, [:item_type, :inserted_at]) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -aeuo pipefail 3 | 4 | DOCKER_TAG=$(git rev-parse --short HEAD) 5 | source .env 6 | 7 | mix test test/paper_trail 8 | mix test test/version 9 | mix test test/uuid 10 | -------------------------------------------------------------------------------- /setup-database.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -oe allexport 3 | source ./.env 4 | 5 | # Prepare Dialyzer if the project has Dialyxer set up 6 | # if mix help dialyzer >/dev/null 2>&1 7 | # then 8 | # echo "\nFound Dialyxer: Setting up PLT..." 9 | # mix do deps.compile, dialyzer --plt 10 | # else 11 | # echo "\nNo Dialyxer config: Skipping setup..." 12 | # fi 13 | 14 | # Wait for Postgres to become available. 15 | until psql -h $PGHOST -U "$PGUSER" -c '\q' 2>/dev/null; do 16 | echo "Postgres is unavailable - sleeping" 17 | sleep 1 18 | done 19 | 20 | echo "\nPostgres is available: continuing with database setup..." 21 | 22 | mix ecto.create 23 | # mix ecto.migrate 24 | 25 | # echo "\nPostgres migrations finished..." 26 | -------------------------------------------------------------------------------- /test/paper_trail/base_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrailTest do 2 | use ExUnit.Case 3 | 4 | import Ecto.Query 5 | 6 | alias PaperTrail.Version 7 | alias SimpleCompany, as: Company 8 | alias SimplePerson, as: Person 9 | alias PaperTrail.Serializer 10 | 11 | @repo PaperTrail.RepoClient.repo() 12 | @create_company_params %{ 13 | name: "Acme LLC", 14 | is_active: true, 15 | city: "Greenwich", 16 | location: %{country: "Brazil"} 17 | } 18 | @update_company_params %{ 19 | city: "Hong Kong", 20 | website: "http://www.acme.com", 21 | facebook: "acme.llc", 22 | location: %{country: "Chile"} 23 | } 24 | 25 | defdelegate serialize(data), to: Serializer 26 | 27 | doctest PaperTrail 28 | 29 | setup_all do 30 | Application.put_env(:paper_trail, :strict_mode, false) 31 | Application.put_env(:paper_trail, :repo, PaperTrail.Repo) 32 | 33 | :ok 34 | end 35 | 36 | setup do 37 | @repo.delete_all(Person) 38 | @repo.delete_all(Company) 39 | @repo.delete_all(Version) 40 | 41 | on_exit(fn -> 42 | @repo.delete_all(Person) 43 | @repo.delete_all(Company) 44 | @repo.delete_all(Version) 45 | end) 46 | 47 | :ok 48 | end 49 | 50 | test "creating a company creates a company version with correct attributes" do 51 | user = create_user() 52 | {:ok, result} = create_company_with_version(@create_company_params, originator: user) 53 | 54 | company_count = Company.count() 55 | version_count = Version.count() 56 | 57 | company = result[:model] |> serialize 58 | version = result[:version] |> serialize 59 | 60 | assert Map.keys(result) == [:model, :version] 61 | assert company_count == 1 62 | assert version_count == 1 63 | 64 | assert Map.drop(company, [:id, :inserted_at, :updated_at]) == %{ 65 | name: "Acme LLC", 66 | is_active: true, 67 | city: "Greenwich", 68 | website: nil, 69 | address: nil, 70 | facebook: nil, 71 | twitter: nil, 72 | founded_in: nil, 73 | location: %{country: "Brazil"} 74 | } 75 | 76 | assert Map.drop(version, [:id, :inserted_at]) == %{ 77 | event: "insert", 78 | item_type: "SimpleCompany", 79 | item_id: company.id, 80 | item_changes: company, 81 | originator_id: user.id, 82 | origin: nil, 83 | meta: nil 84 | } 85 | 86 | assert company == first(Company, :id) |> @repo.one |> serialize 87 | end 88 | 89 | test "PaperTrail.insert/2 with an error returns and error tuple like Repo.insert/2" do 90 | result = create_company_with_version(%{name: nil, is_active: true, city: "Greenwich"}) 91 | 92 | ecto_result = 93 | Company.changeset(%Company{}, %{name: nil, is_active: true, city: "Greenwich"}) 94 | |> @repo.insert 95 | 96 | assert result == ecto_result 97 | end 98 | 99 | test "PaperTrail.insert/2 passes ecto options through (e.g. upsert options)" do 100 | user = create_user() 101 | {:ok, _result} = create_company_with_version(@create_company_params, originator: user) 102 | 103 | new_create_company_params = @create_company_params |> Map.replace!(:city, "Barcelona") 104 | 105 | ecto_options = [on_conflict: {:replace_all_except, ~w{name}a}, conflict_target: :name] 106 | 107 | {:ok, result} = 108 | create_company_with_version(new_create_company_params, 109 | originator: user, 110 | ecto_options: ecto_options 111 | ) 112 | 113 | assert Company.count() == 1 114 | assert Version.count() == 2 115 | 116 | assert Map.take(serialize(result[:model]), [:name, :city]) == %{ 117 | name: "Acme LLC", 118 | city: "Barcelona" 119 | } 120 | end 121 | 122 | test "PaperTrail.insert_or_update/2 creates a new record when it does not already exist" do 123 | user = create_user() 124 | 125 | {:ok, result} = 126 | Company.changeset(%Company{}, @create_company_params) 127 | |> PaperTrail.insert_or_update(originator: user) 128 | 129 | company_count = Company.count() 130 | version_count = Version.count() 131 | 132 | company = result[:model] |> serialize 133 | version = result[:version] |> serialize 134 | 135 | assert Map.keys(result) == [:model, :version] 136 | assert company_count == 1 137 | assert version_count == 1 138 | 139 | assert Map.drop(company, [:id, :inserted_at, :updated_at]) == %{ 140 | name: "Acme LLC", 141 | is_active: true, 142 | city: "Greenwich", 143 | website: nil, 144 | address: nil, 145 | facebook: nil, 146 | twitter: nil, 147 | founded_in: nil, 148 | location: %{country: "Brazil"} 149 | } 150 | 151 | assert Map.drop(version, [:id, :inserted_at]) == %{ 152 | event: "insert", 153 | item_type: "SimpleCompany", 154 | item_id: company.id, 155 | item_changes: company, 156 | originator_id: user.id, 157 | origin: nil, 158 | meta: nil 159 | } 160 | 161 | assert company == first(Company, :id) |> @repo.one |> serialize 162 | end 163 | 164 | test "PaperTrail.insert_or_update/2 updates a record when already exists" do 165 | user = create_user() 166 | {:ok, insert_result} = create_company_with_version() 167 | 168 | {:ok, result} = 169 | Company.changeset(insert_result[:model], @update_company_params) 170 | |> PaperTrail.insert_or_update(originator: user) 171 | 172 | company_count = Company.count() 173 | version_count = Version.count() 174 | 175 | company = result[:model] |> serialize 176 | version = result[:version] |> serialize 177 | 178 | assert Map.keys(result) == [:model, :version] 179 | assert company_count == 1 180 | assert version_count == 2 181 | 182 | assert Map.drop(company, [:id, :inserted_at, :updated_at]) == %{ 183 | name: "Acme LLC", 184 | is_active: true, 185 | city: "Hong Kong", 186 | website: "http://www.acme.com", 187 | address: nil, 188 | facebook: "acme.llc", 189 | twitter: nil, 190 | founded_in: nil, 191 | location: %{country: "Chile"} 192 | } 193 | 194 | assert Map.drop(version, [:id, :inserted_at]) == %{ 195 | event: "update", 196 | item_type: "SimpleCompany", 197 | item_id: company.id, 198 | item_changes: %{ 199 | city: "Hong Kong", 200 | website: "http://www.acme.com", 201 | facebook: "acme.llc", 202 | location: %{country: "Chile"} 203 | }, 204 | originator_id: user.id, 205 | origin: nil, 206 | meta: nil 207 | } 208 | 209 | assert company == first(Company, :id) |> @repo.one |> serialize 210 | end 211 | 212 | test "updating a company with originator creates a correct company version" do 213 | user = create_user() 214 | {:ok, insert_result} = create_company_with_version() 215 | 216 | {:ok, result} = 217 | update_company_with_version( 218 | insert_result[:model], 219 | @update_company_params, 220 | user: user 221 | ) 222 | 223 | company_count = Company.count() 224 | version_count = Version.count() 225 | 226 | company = result[:model] |> serialize 227 | version = result[:version] |> serialize 228 | 229 | assert Map.keys(result) == [:model, :version] 230 | assert company_count == 1 231 | assert version_count == 2 232 | 233 | assert Map.drop(company, [:id, :inserted_at, :updated_at]) == %{ 234 | name: "Acme LLC", 235 | is_active: true, 236 | city: "Hong Kong", 237 | website: "http://www.acme.com", 238 | address: nil, 239 | facebook: "acme.llc", 240 | twitter: nil, 241 | founded_in: nil, 242 | location: %{country: "Chile"} 243 | } 244 | 245 | assert Map.drop(version, [:id, :inserted_at]) == %{ 246 | event: "update", 247 | item_type: "SimpleCompany", 248 | item_id: company.id, 249 | item_changes: %{ 250 | city: "Hong Kong", 251 | website: "http://www.acme.com", 252 | facebook: "acme.llc", 253 | location: %{country: "Chile"} 254 | }, 255 | originator_id: user.id, 256 | origin: nil, 257 | meta: nil 258 | } 259 | 260 | assert company == first(Company, :id) |> @repo.one |> serialize 261 | end 262 | 263 | test "updating a company with originator[user] creates a correct company version" do 264 | user = create_user() 265 | {:ok, insert_result} = create_company_with_version() 266 | 267 | {:ok, result} = 268 | update_company_with_version( 269 | insert_result[:model], 270 | @update_company_params, 271 | user: user 272 | ) 273 | 274 | company_count = Company.count() 275 | version_count = Version.count() 276 | 277 | company = result[:model] |> serialize 278 | version = result[:version] |> serialize 279 | 280 | assert Map.keys(result) == [:model, :version] 281 | assert company_count == 1 282 | assert version_count == 2 283 | 284 | assert Map.drop(company, [:id, :inserted_at, :updated_at]) == %{ 285 | name: "Acme LLC", 286 | is_active: true, 287 | city: "Hong Kong", 288 | website: "http://www.acme.com", 289 | address: nil, 290 | facebook: "acme.llc", 291 | twitter: nil, 292 | founded_in: nil, 293 | location: %{country: "Chile"} 294 | } 295 | 296 | assert Map.drop(version, [:id, :inserted_at]) == %{ 297 | event: "update", 298 | item_type: "SimpleCompany", 299 | item_id: company.id, 300 | item_changes: %{ 301 | city: "Hong Kong", 302 | website: "http://www.acme.com", 303 | facebook: "acme.llc", 304 | location: %{country: "Chile"} 305 | }, 306 | originator_id: user.id, 307 | origin: nil, 308 | meta: nil 309 | } 310 | 311 | assert company == first(Company, :id) |> @repo.one |> serialize 312 | end 313 | 314 | test "PaperTrail.update/2 with an error returns and error tuple like Repo.update/2" do 315 | {:ok, insert_result} = create_company_with_version() 316 | company = insert_result[:model] 317 | 318 | result = 319 | update_company_with_version(company, %{ 320 | name: nil, 321 | city: "Hong Kong", 322 | website: "http://www.acme.com", 323 | facebook: "acme.llc" 324 | }) 325 | 326 | ecto_result = 327 | Company.changeset(company, %{ 328 | name: nil, 329 | city: "Hong Kong", 330 | website: "http://www.acme.com", 331 | facebook: "acme.llc" 332 | }) 333 | |> @repo.update 334 | 335 | assert result == ecto_result 336 | end 337 | 338 | test "deleting a company creates a company version with correct attributes" do 339 | user = create_user() 340 | {:ok, insert_result} = create_company_with_version() 341 | {:ok, update_result} = update_company_with_version(insert_result[:model]) 342 | company_before_deletion = first(Company, :id) |> @repo.one |> serialize 343 | {:ok, result} = PaperTrail.delete(update_result[:model], originator: user) 344 | 345 | company_count = Company.count() 346 | version_count = Version.count() 347 | 348 | company = result[:model] |> serialize 349 | version = result[:version] |> serialize 350 | 351 | assert Map.keys(result) == [:model, :version] 352 | assert company_count == 0 353 | assert version_count == 3 354 | 355 | assert Map.drop(company, [:id, :inserted_at, :updated_at]) == %{ 356 | name: "Acme LLC", 357 | is_active: true, 358 | city: "Hong Kong", 359 | website: "http://www.acme.com", 360 | address: nil, 361 | facebook: "acme.llc", 362 | twitter: nil, 363 | founded_in: nil, 364 | location: %{country: "Chile"} 365 | } 366 | 367 | assert Map.drop(version, [:id, :inserted_at]) == %{ 368 | event: "delete", 369 | item_type: "SimpleCompany", 370 | item_id: company.id, 371 | item_changes: %{ 372 | id: company.id, 373 | inserted_at: company.inserted_at, 374 | updated_at: company.updated_at, 375 | name: "Acme LLC", 376 | is_active: true, 377 | website: "http://www.acme.com", 378 | city: "Hong Kong", 379 | address: nil, 380 | facebook: "acme.llc", 381 | twitter: nil, 382 | founded_in: nil, 383 | location: %{country: "Chile"} 384 | }, 385 | originator_id: user.id, 386 | origin: nil, 387 | meta: nil 388 | } 389 | 390 | assert company == company_before_deletion 391 | end 392 | 393 | test "delete works with a changeset" do 394 | user = create_user() 395 | {:ok, insert_result} = create_company_with_version() 396 | {:ok, _update_result} = update_company_with_version(insert_result[:model]) 397 | company_before_deletion = first(Company, :id) |> @repo.one 398 | 399 | changeset = Company.changeset(company_before_deletion, %{}) 400 | {:ok, result} = PaperTrail.delete(changeset, originator: user) 401 | 402 | company_count = Company.count() 403 | version_count = Version.count() 404 | 405 | company = result[:model] |> serialize 406 | version = result[:version] |> serialize 407 | 408 | assert Map.keys(result) == [:model, :version] 409 | assert company_count == 0 410 | assert version_count == 3 411 | 412 | assert Map.drop(company, [:id, :inserted_at, :updated_at]) == %{ 413 | name: "Acme LLC", 414 | is_active: true, 415 | city: "Hong Kong", 416 | website: "http://www.acme.com", 417 | address: nil, 418 | facebook: "acme.llc", 419 | twitter: nil, 420 | founded_in: nil, 421 | location: %{country: "Chile"} 422 | } 423 | 424 | assert Map.drop(version, [:id, :inserted_at]) == %{ 425 | event: "delete", 426 | item_type: "SimpleCompany", 427 | item_id: company.id, 428 | item_changes: %{ 429 | id: company.id, 430 | inserted_at: company.inserted_at, 431 | updated_at: company.updated_at, 432 | name: "Acme LLC", 433 | is_active: true, 434 | website: "http://www.acme.com", 435 | city: "Hong Kong", 436 | address: nil, 437 | facebook: "acme.llc", 438 | twitter: nil, 439 | founded_in: nil, 440 | location: %{country: "Chile"} 441 | }, 442 | originator_id: user.id, 443 | origin: nil, 444 | meta: nil 445 | } 446 | 447 | assert company == serialize(company_before_deletion) 448 | end 449 | 450 | test "PaperTrail.delete/2 with an error returns and error tuple like Repo.delete/2" do 451 | {:ok, insert_company_result} = create_company_with_version() 452 | 453 | Person.changeset(%Person{}, %{ 454 | first_name: "Izel", 455 | last_name: "Nakri", 456 | gender: true, 457 | company_id: insert_company_result[:model].id 458 | }) 459 | |> PaperTrail.insert() 460 | 461 | {:error, ecto_result} = insert_company_result[:model] |> Company.changeset() |> @repo.delete 462 | {:error, result} = insert_company_result[:model] |> Company.changeset() |> PaperTrail.delete() 463 | 464 | assert Map.drop(result, [:repo_opts]) == Map.drop(ecto_result, [:repo_opts]) 465 | end 466 | 467 | test "creating a person with meta tag creates a person version with correct attributes" do 468 | create_company_with_version() 469 | 470 | {:ok, new_company_result} = 471 | Company.changeset(%Company{}, %{ 472 | name: "Another Company Corp.", 473 | is_active: true, 474 | address: "Sesame street 100/3, 101010" 475 | }) 476 | |> PaperTrail.insert() 477 | 478 | {:ok, result} = 479 | Person.changeset(%Person{}, %{ 480 | first_name: "Izel", 481 | last_name: "Nakri", 482 | gender: true, 483 | company_id: new_company_result[:model].id 484 | }) 485 | |> PaperTrail.insert(origin: "admin", meta: %{linkname: "izelnakri"}) 486 | 487 | person_count = Person.count() 488 | version_count = Version.count() 489 | 490 | person = result[:model] |> serialize 491 | version = result[:version] |> serialize 492 | 493 | assert Map.keys(result) == [:model, :version] 494 | assert person_count == 1 495 | assert version_count == 3 496 | 497 | assert Map.drop(person, [:id, :inserted_at, :updated_at]) == %{ 498 | first_name: "Izel", 499 | last_name: "Nakri", 500 | gender: true, 501 | visit_count: nil, 502 | birthdate: nil, 503 | company_id: new_company_result[:model].id, 504 | plural: [], 505 | singular: nil 506 | } 507 | 508 | assert Map.drop(version, [:id, :inserted_at]) == %{ 509 | event: "insert", 510 | item_type: "SimplePerson", 511 | item_id: person.id, 512 | item_changes: person, 513 | originator_id: nil, 514 | origin: "admin", 515 | meta: %{linkname: "izelnakri"} 516 | } 517 | 518 | assert person == first(Person, :id) |> @repo.one |> serialize 519 | end 520 | 521 | test "updating a person creates a person version with correct attributes" do 522 | {:ok, initial_company_insertion} = 523 | create_company_with_version(%{ 524 | name: "Acme LLC", 525 | website: "http://www.acme.com" 526 | }) 527 | 528 | {:ok, target_company_insertion} = 529 | create_company_with_version(%{ 530 | name: "Another Company Corp.", 531 | is_active: true, 532 | address: "Sesame street 100/3, 101010" 533 | }) 534 | 535 | {:ok, insert_person_result} = 536 | Person.changeset(%Person{}, %{ 537 | first_name: "Izel", 538 | last_name: "Nakri", 539 | gender: true, 540 | company_id: target_company_insertion[:model].id 541 | }) 542 | |> PaperTrail.insert(origin: "admin") 543 | 544 | {:ok, result} = 545 | Person.changeset(insert_person_result[:model], %{ 546 | first_name: "Isaac", 547 | visit_count: 10, 548 | birthdate: ~D[1992-04-01], 549 | company_id: initial_company_insertion[:model].id 550 | }) 551 | |> PaperTrail.update(origin: "scraper", meta: %{linkname: "izelnakri"}) 552 | 553 | person_count = Person.count() 554 | version_count = Version.count() 555 | 556 | person = result[:model] |> serialize 557 | version = result[:version] |> serialize 558 | 559 | assert Map.keys(result) == [:model, :version] 560 | assert person_count == 1 561 | assert version_count == 4 562 | 563 | assert Map.drop(person, [:id, :inserted_at, :updated_at]) == %{ 564 | company_id: initial_company_insertion[:model].id, 565 | first_name: "Isaac", 566 | visit_count: 10, 567 | birthdate: ~D[1992-04-01], 568 | last_name: "Nakri", 569 | gender: true, 570 | plural: [], 571 | singular: nil 572 | } 573 | 574 | assert Map.drop(version, [:id, :inserted_at]) == %{ 575 | event: "update", 576 | item_type: "SimplePerson", 577 | item_id: person.id, 578 | item_changes: %{ 579 | first_name: "Isaac", 580 | visit_count: 10, 581 | birthdate: ~D[1992-04-01], 582 | company_id: initial_company_insertion[:model].id 583 | }, 584 | originator_id: nil, 585 | origin: "scraper", 586 | meta: %{linkname: "izelnakri"} 587 | } 588 | 589 | assert person == first(Person, :id) |> @repo.one |> serialize 590 | end 591 | 592 | test "deleting a person creates a person version with correct attributes" do 593 | create_company_with_version(%{name: "Acme LLC", website: "http://www.acme.com"}) 594 | 595 | {:ok, target_company_insertion} = 596 | create_company_with_version(%{ 597 | name: "Another Company Corp.", 598 | is_active: true, 599 | address: "Sesame street 100/3, 101010" 600 | }) 601 | 602 | # add link name later on 603 | {:ok, insert_person_result} = 604 | Person.changeset(%Person{}, %{ 605 | first_name: "Izel", 606 | last_name: "Nakri", 607 | gender: true, 608 | company_id: target_company_insertion[:model].id 609 | }) 610 | |> PaperTrail.insert(origin: "admin") 611 | 612 | {:ok, update_result} = 613 | Person.changeset(insert_person_result[:model], %{ 614 | first_name: "Isaac", 615 | visit_count: 10, 616 | birthdate: ~D[1992-04-01], 617 | company_id: target_company_insertion[:model].id 618 | }) 619 | |> PaperTrail.update(origin: "scraper", meta: %{linkname: "izelnakri"}) 620 | 621 | person_before_deletion = first(Person, :id) |> @repo.one |> serialize 622 | 623 | {:ok, result} = 624 | PaperTrail.delete( 625 | update_result[:model], 626 | origin: "admin", 627 | meta: %{linkname: "izelnakri"} 628 | ) 629 | 630 | person_count = Person.count() 631 | version_count = Version.count() 632 | 633 | assert Map.keys(result) == [:model, :version] 634 | old_person = update_result[:model] |> serialize 635 | version = result[:version] |> serialize 636 | 637 | assert person_count == 0 638 | assert version_count == 5 639 | 640 | assert Map.drop(version, [:id, :inserted_at]) == %{ 641 | event: "delete", 642 | item_type: "SimplePerson", 643 | item_id: old_person.id, 644 | item_changes: %{ 645 | id: old_person.id, 646 | inserted_at: old_person.inserted_at, 647 | updated_at: old_person.updated_at, 648 | first_name: "Isaac", 649 | last_name: "Nakri", 650 | gender: true, 651 | visit_count: 10, 652 | birthdate: ~D[1992-04-01], 653 | company_id: target_company_insertion[:model].id, 654 | plural: [], 655 | singular: nil 656 | }, 657 | originator_id: nil, 658 | origin: "admin", 659 | meta: %{linkname: "izelnakri"} 660 | } 661 | 662 | assert old_person == person_before_deletion 663 | end 664 | 665 | test "works with nil embed" do 666 | {:ok, target_company_insertion} = 667 | create_company_with_version(%{ 668 | name: "Another Company Corp.", 669 | is_active: true, 670 | address: "Sesame street 100/3, 101010" 671 | }) 672 | 673 | {:ok, insert_person_result} = 674 | Person.changeset(%Person{}, %{ 675 | first_name: "Izel", 676 | last_name: "Nakri", 677 | gender: true, 678 | company_id: target_company_insertion[:model].id, 679 | singular: %{} 680 | }) 681 | |> PaperTrail.insert(origin: "admin") 682 | 683 | assert {:ok, insert_person_result} = 684 | Person.changeset(insert_person_result[:model], %{ 685 | singular: nil 686 | }) 687 | |> PaperTrail.update(origin: "admin") 688 | end 689 | 690 | test "updating a company with current params should not create a version" do 691 | {:ok, insert_result} = create_company_with_version() 692 | 693 | insert_result_version = PaperTrail.get_version(insert_result[:model]) 694 | version_count_before_update = PaperTrail.Version.count() 695 | 696 | {:ok, update_result} = 697 | update_company_with_version( 698 | insert_result[:model], 699 | @create_company_params, 700 | [] 701 | ) 702 | 703 | version_count_after_update = PaperTrail.Version.count() 704 | update_result_version = PaperTrail.get_version(update_result[:model]) 705 | 706 | assert version_count_before_update == version_count_after_update 707 | assert insert_result_version == update_result_version 708 | end 709 | 710 | defp create_user do 711 | User.changeset(%User{}, %{token: "fake-token", username: "izelnakri"}) |> @repo.insert! 712 | end 713 | 714 | defp create_company_with_version(params \\ @create_company_params, options \\ []) do 715 | Company.changeset(%Company{}, params) |> PaperTrail.insert(options) 716 | end 717 | 718 | defp update_company_with_version(company, params \\ @update_company_params, options \\ []) do 719 | Company.changeset(company, params) |> PaperTrail.update(options) 720 | end 721 | end 722 | -------------------------------------------------------------------------------- /test/paper_trail/strict_mode_test.exs: -------------------------------------------------------------------------------- 1 | # test one with user:, one with originator 2 | defmodule PaperTrailStrictModeTest do 3 | use ExUnit.Case 4 | 5 | import Ecto.Query 6 | 7 | alias PaperTrail.Version 8 | alias StrictCompany, as: Company 9 | alias StrictPerson, as: Person 10 | alias PaperTrail.Serializer 11 | 12 | @repo PaperTrail.RepoClient.repo() 13 | @create_company_params %{name: "Acme LLC", is_active: true, city: "Greenwich"} 14 | @update_company_params %{ 15 | city: "Hong Kong", 16 | website: "http://www.acme.com", 17 | facebook: "acme.llc" 18 | } 19 | 20 | defdelegate serialize(data), to: Serializer 21 | 22 | doctest PaperTrail 23 | 24 | setup_all do 25 | Application.put_env(:paper_trail, :strict_mode, true) 26 | Application.put_env(:paper_trail, :repo, PaperTrail.Repo) 27 | 28 | :ok 29 | end 30 | 31 | setup do 32 | @repo.delete_all(Person) 33 | @repo.delete_all(Company) 34 | @repo.delete_all(Version) 35 | 36 | on_exit(fn -> 37 | @repo.delete_all(Person) 38 | @repo.delete_all(Company) 39 | @repo.delete_all(Version) 40 | end) 41 | 42 | :ok 43 | end 44 | 45 | test "creating a company creates a company version with correct attributes" do 46 | user = create_user() 47 | {:ok, result} = create_company_with_version(@create_company_params, user: user) 48 | 49 | company_count = Company.count() 50 | version_count = Version.count() 51 | 52 | company = result[:model] |> serialize() 53 | version = result[:version] |> serialize() 54 | 55 | assert Map.keys(result) == [:model, :version] 56 | assert company_count == 1 57 | assert version_count == 1 58 | 59 | assert Map.drop(company, [:id, :inserted_at, :updated_at]) == %{ 60 | name: "Acme LLC", 61 | is_active: true, 62 | city: "Greenwich", 63 | website: nil, 64 | address: nil, 65 | facebook: nil, 66 | twitter: nil, 67 | founded_in: nil, 68 | first_version_id: version.id, 69 | current_version_id: version.id 70 | } 71 | 72 | assert Map.drop(version, [:id, :inserted_at]) == %{ 73 | event: "insert", 74 | item_type: "StrictCompany", 75 | item_id: company.id, 76 | item_changes: company, 77 | originator_id: user.id, 78 | origin: nil, 79 | meta: nil 80 | } 81 | 82 | assert company == first(Company, :id) |> @repo.one |> serialize 83 | end 84 | 85 | test "creating a company without changeset creates a company version with correct attributes" do 86 | {:ok, result} = PaperTrail.insert(%Company{name: "Acme LLC"}) 87 | company_count = Company.count() 88 | version_count = Version.count() 89 | 90 | company = result[:model] |> serialize 91 | version = result[:version] |> serialize 92 | 93 | assert Map.keys(result) == [:model, :version] 94 | assert company_count == 1 95 | assert version_count == 1 96 | 97 | assert Map.drop(company, [:id, :inserted_at, :updated_at]) == %{ 98 | name: "Acme LLC", 99 | is_active: nil, 100 | city: nil, 101 | website: nil, 102 | address: nil, 103 | facebook: nil, 104 | twitter: nil, 105 | founded_in: nil, 106 | first_version_id: version.id, 107 | current_version_id: version.id 108 | } 109 | 110 | assert Map.drop(version, [:id, :inserted_at]) == %{ 111 | event: "insert", 112 | item_type: "StrictCompany", 113 | item_id: company.id, 114 | item_changes: company, 115 | originator_id: nil, 116 | origin: nil, 117 | meta: nil 118 | } 119 | end 120 | 121 | test "PaperTrail.insert/2 with an error returns and error tuple like Repo.insert/2" do 122 | result = create_company_with_version(%{name: nil, is_active: true, city: "Greenwich"}) 123 | 124 | ecto_result = 125 | Company.changeset(%Company{}, %{name: nil, is_active: true, city: "Greenwich"}) 126 | |> @repo.insert 127 | 128 | assert result == ecto_result 129 | end 130 | 131 | test "PaperTrail.insert/2 passes ecto options through (e.g. upsert options)" do 132 | user = create_user() 133 | {:ok, _result} = create_company_with_version(@create_company_params, originator: user) 134 | 135 | new_create_company_params = @create_company_params |> Map.replace!(:city, "Barcelona") 136 | 137 | ecto_options = [on_conflict: {:replace_all_except, ~w{name}a}, conflict_target: :name] 138 | 139 | {:ok, result} = 140 | create_company_with_version(new_create_company_params, 141 | originator: user, 142 | ecto_options: ecto_options 143 | ) 144 | 145 | assert Company.count() == 1 146 | assert Version.count() == 2 147 | 148 | assert Map.take(serialize(result[:model]), [:name, :city]) == %{ 149 | name: "Acme LLC", 150 | city: "Barcelona" 151 | } 152 | end 153 | 154 | test "updating a company creates a company version with correct item_changes" do 155 | user = create_user() 156 | {:ok, insert_company_result} = create_company_with_version() 157 | 158 | {:ok, result} = 159 | update_company_with_version( 160 | insert_company_result[:model], 161 | @update_company_params, 162 | originator: user 163 | ) 164 | 165 | company_count = Company.count() 166 | version_count = Version.count() 167 | 168 | company = result[:model] |> serialize 169 | version = result[:version] |> serialize 170 | 171 | assert Map.keys(result) == [:model, :version] 172 | assert company_count == 1 173 | assert version_count == 2 174 | 175 | assert Map.drop(company, [:id, :inserted_at, :updated_at]) == %{ 176 | name: "Acme LLC", 177 | is_active: true, 178 | city: "Hong Kong", 179 | website: "http://www.acme.com", 180 | address: nil, 181 | facebook: "acme.llc", 182 | twitter: nil, 183 | founded_in: nil, 184 | first_version_id: insert_company_result[:version].id, 185 | current_version_id: version.id 186 | } 187 | 188 | assert Map.drop(version, [:id, :inserted_at]) == %{ 189 | event: "update", 190 | item_type: "StrictCompany", 191 | item_id: company.id, 192 | item_changes: %{ 193 | city: "Hong Kong", 194 | website: "http://www.acme.com", 195 | facebook: "acme.llc", 196 | current_version_id: version.id 197 | }, 198 | originator_id: user.id, 199 | origin: nil, 200 | meta: nil 201 | } 202 | 203 | assert company == first(Company, :id) |> @repo.one |> serialize 204 | end 205 | 206 | test "PaperTrail.update/2 with an error returns and error tuple like Repo.update/2" do 207 | {:ok, insert_result} = create_company_with_version() 208 | company = insert_result[:model] 209 | 210 | result = 211 | update_company_with_version(company, %{ 212 | name: nil, 213 | city: "Hong Kong", 214 | website: "http://www.acme.com", 215 | facebook: "acme.llc" 216 | }) 217 | 218 | ecto_result = 219 | Company.changeset(company, %{ 220 | name: nil, 221 | city: "Hong Kong", 222 | website: "http://www.acme.com", 223 | facebook: "acme.llc" 224 | }) 225 | |> @repo.update 226 | 227 | assert result == ecto_result 228 | end 229 | 230 | test "deleting a company creates a company version with correct attributes" do 231 | user = create_user() 232 | {:ok, insert_company_result} = create_company_with_version() 233 | {:ok, update_company_result} = update_company_with_version(insert_company_result[:model]) 234 | company_before_deletion = first(Company, :id) |> @repo.one |> serialize 235 | {:ok, result} = PaperTrail.delete(update_company_result[:model], user: user) 236 | 237 | company_count = Company.count() 238 | version_count = Version.count() 239 | 240 | old_company = result[:model] |> serialize() 241 | version = result[:version] |> serialize() 242 | 243 | assert Map.keys(result) == [:model, :version] 244 | assert company_count == 0 245 | assert version_count == 3 246 | 247 | assert Map.drop(old_company, [:id, :inserted_at, :updated_at]) == %{ 248 | name: "Acme LLC", 249 | is_active: true, 250 | city: "Hong Kong", 251 | website: "http://www.acme.com", 252 | address: nil, 253 | facebook: "acme.llc", 254 | twitter: nil, 255 | founded_in: nil, 256 | first_version_id: insert_company_result[:version].id, 257 | current_version_id: update_company_result[:version].id 258 | } 259 | 260 | assert Map.drop(version, [:id, :inserted_at]) == %{ 261 | event: "delete", 262 | item_type: "StrictCompany", 263 | item_id: old_company.id, 264 | item_changes: %{ 265 | id: old_company.id, 266 | inserted_at: old_company.inserted_at, 267 | updated_at: old_company.updated_at, 268 | name: "Acme LLC", 269 | is_active: true, 270 | website: "http://www.acme.com", 271 | city: "Hong Kong", 272 | address: nil, 273 | facebook: "acme.llc", 274 | twitter: nil, 275 | founded_in: nil, 276 | first_version_id: insert_company_result[:version].id, 277 | current_version_id: update_company_result[:version].id 278 | }, 279 | originator_id: user.id, 280 | origin: nil, 281 | meta: nil 282 | } 283 | 284 | assert old_company == company_before_deletion 285 | end 286 | 287 | test "PaperTrail.delete/2 with an error returns and error tuple like Repo.delete/2" do 288 | {:ok, insert_company_result} = create_company_with_version() 289 | 290 | Person.changeset(%Person{}, %{ 291 | first_name: "Izel", 292 | last_name: "Nakri", 293 | gender: true, 294 | company_id: insert_company_result[:model].id 295 | }) 296 | |> PaperTrail.insert() 297 | 298 | {:error, ecto_result} = insert_company_result[:model] |> Company.changeset() |> @repo.delete 299 | {:error, result} = insert_company_result[:model] |> Company.changeset() |> PaperTrail.delete() 300 | 301 | assert Map.drop(result, [:repo_opts]) == Map.drop(ecto_result, [:repo_opts]) 302 | end 303 | 304 | test "creating a person with meta tag creates a person version with correct attributes" do 305 | create_company_with_version(%{name: "Acme LLC", website: "http://www.acme.com"}) 306 | 307 | {:ok, insert_company_result} = 308 | create_company_with_version(%{ 309 | name: "Another Company Corp.", 310 | is_active: true, 311 | address: "Sesame street 100/3, 101010" 312 | }) 313 | 314 | {:ok, result} = 315 | Person.changeset(%Person{}, %{ 316 | first_name: "Izel", 317 | last_name: "Nakri", 318 | gender: true, 319 | company_id: insert_company_result[:model].id 320 | }) 321 | |> PaperTrail.insert(origin: "admin", meta: %{linkname: "izelnakri"}) 322 | 323 | person_count = Person.count() 324 | version_count = Version.count() 325 | 326 | person = result[:model] |> serialize 327 | version = result[:version] |> serialize 328 | 329 | assert Map.keys(result) == [:model, :version] 330 | assert person_count == 1 331 | assert version_count == 3 332 | 333 | assert Map.drop(person, [:id, :inserted_at, :updated_at]) == %{ 334 | first_name: "Izel", 335 | last_name: "Nakri", 336 | gender: true, 337 | visit_count: nil, 338 | birthdate: nil, 339 | company_id: insert_company_result[:model].id, 340 | first_version_id: result[:version].id, 341 | current_version_id: result[:version].id 342 | } 343 | 344 | assert Map.drop(version, [:id, :inserted_at]) == %{ 345 | event: "insert", 346 | item_type: "StrictPerson", 347 | item_id: person.id, 348 | item_changes: person, 349 | originator_id: nil, 350 | origin: "admin", 351 | meta: %{linkname: "izelnakri"} 352 | } 353 | 354 | assert person == first(Person, :id) |> @repo.one |> serialize 355 | end 356 | 357 | test "updating a person creates a person version with correct attributes" do 358 | {:ok, insert_company_result} = 359 | create_company_with_version(%{ 360 | name: "Acme LLC", 361 | website: "http://www.acme.com" 362 | }) 363 | 364 | {:ok, target_company_insertion} = 365 | create_company_with_version(%{ 366 | name: "Another Company Corp.", 367 | is_active: true, 368 | address: "Sesame street 100/3, 101010" 369 | }) 370 | 371 | {:ok, insert_person_result} = 372 | Person.changeset(%Person{}, %{ 373 | first_name: "Izel", 374 | last_name: "Nakri", 375 | gender: true, 376 | company_id: target_company_insertion[:model].id 377 | }) 378 | |> PaperTrail.insert(origin: "admin") 379 | 380 | {:ok, result} = 381 | Person.changeset(insert_person_result[:model], %{ 382 | first_name: "Isaac", 383 | visit_count: 10, 384 | birthdate: ~D[1992-04-01], 385 | company_id: insert_company_result[:model].id 386 | }) 387 | |> PaperTrail.update(origin: "scraper", meta: %{linkname: "izelnakri"}) 388 | 389 | person_count = Person.count() 390 | version_count = Version.count() 391 | 392 | person = result[:model] |> serialize 393 | version = result[:version] |> serialize 394 | 395 | assert Map.keys(result) == [:model, :version] 396 | assert person_count == 1 397 | assert version_count == 4 398 | 399 | assert Map.drop(person, [:id, :inserted_at, :updated_at]) == %{ 400 | company_id: insert_company_result[:model].id, 401 | first_name: "Isaac", 402 | visit_count: 10, 403 | # this is the only problem 404 | birthdate: ~D[1992-04-01], 405 | last_name: "Nakri", 406 | gender: true, 407 | first_version_id: insert_person_result[:version].id, 408 | current_version_id: version.id 409 | } 410 | 411 | assert Map.drop(version, [:id, :inserted_at]) == %{ 412 | event: "update", 413 | item_type: "StrictPerson", 414 | item_id: person.id, 415 | item_changes: %{ 416 | first_name: "Isaac", 417 | visit_count: 10, 418 | birthdate: ~D[1992-04-01], 419 | current_version_id: version.id, 420 | company_id: insert_company_result[:model].id 421 | }, 422 | originator_id: nil, 423 | origin: "scraper", 424 | meta: %{linkname: "izelnakri"} 425 | } 426 | 427 | assert person == first(Person, :id) |> @repo.one |> serialize 428 | end 429 | 430 | test "deleting a person creates a person version with correct attributes" do 431 | create_company_with_version(%{name: "Acme LLC", website: "http://www.acme.com"}) 432 | 433 | {:ok, target_company_insertion} = 434 | create_company_with_version(%{ 435 | name: "Another Company Corp.", 436 | is_active: true, 437 | address: "Sesame street 100/3, 101010" 438 | }) 439 | 440 | {:ok, insert_person_result} = 441 | Person.changeset(%Person{}, %{ 442 | first_name: "Izel", 443 | last_name: "Nakri", 444 | gender: true, 445 | company_id: target_company_insertion[:model].id 446 | }) 447 | |> PaperTrail.insert(origin: "admin") 448 | 449 | {:ok, update_person_result} = 450 | Person.changeset(insert_person_result[:model], %{ 451 | first_name: "Isaac", 452 | visit_count: 10, 453 | birthdate: ~D[1992-04-01] 454 | }) 455 | |> PaperTrail.update(origin: "scraper", meta: %{linkname: "izelnakri"}) 456 | 457 | person_before_deletion = first(Person, :id) |> @repo.one |> serialize 458 | 459 | {:ok, result} = 460 | PaperTrail.delete( 461 | update_person_result[:model], 462 | origin: "admin", 463 | meta: %{linkname: "izelnakri"} 464 | ) 465 | 466 | person_count = Person.count() 467 | version_count = Version.count() 468 | 469 | old_person = result[:model] |> serialize 470 | version = result[:version] |> serialize 471 | 472 | assert Map.keys(result) == [:model, :version] 473 | assert person_count == 0 474 | assert version_count == 5 475 | 476 | assert Map.drop(version, [:id, :inserted_at]) == %{ 477 | event: "delete", 478 | item_type: "StrictPerson", 479 | item_id: old_person.id, 480 | item_changes: %{ 481 | id: old_person.id, 482 | inserted_at: old_person.inserted_at, 483 | updated_at: old_person.updated_at, 484 | first_name: "Isaac", 485 | last_name: "Nakri", 486 | gender: true, 487 | visit_count: 10, 488 | birthdate: ~D[1992-04-01], 489 | company_id: target_company_insertion[:model].id, 490 | first_version_id: insert_person_result[:version].id, 491 | current_version_id: update_person_result[:version].id 492 | }, 493 | originator_id: nil, 494 | origin: "admin", 495 | meta: %{linkname: "izelnakri"} 496 | } 497 | 498 | assert old_person == person_before_deletion 499 | end 500 | 501 | defp create_user do 502 | User.changeset(%User{}, %{token: "fake-token", username: "izelnakri"}) |> @repo.insert! 503 | end 504 | 505 | defp create_company_with_version(params \\ @create_company_params, options \\ []) do 506 | Company.changeset(%Company{}, params) |> PaperTrail.insert(options) 507 | end 508 | 509 | defp update_company_with_version(company, params \\ @update_company_params, options \\ []) do 510 | Company.changeset(company, params) |> PaperTrail.update(options) 511 | end 512 | end 513 | -------------------------------------------------------------------------------- /test/support/multi_tenant_helper.ex: -------------------------------------------------------------------------------- 1 | defmodule PaperTrailTest.MultiTenantHelper do 2 | alias Ecto.Adapters.SQL 3 | alias Ecto.Changeset 4 | 5 | @migrations_path "migrations" 6 | @tenant "tenant_id" 7 | 8 | def add_prefix_to_changeset(%Changeset{} = changeset) do 9 | %{changeset | data: add_prefix_to_struct(changeset.data)} 10 | end 11 | 12 | def add_prefix_to_query(query) do 13 | query |> Ecto.Queryable.to_query() |> Map.put(:prefix, @tenant) 14 | end 15 | 16 | def add_prefix_to_struct(%{__struct__: _} = model) do 17 | Ecto.put_meta(model, prefix: @tenant) 18 | end 19 | 20 | def setup_tenant(repo, direction \\ :up, opts \\ [all: true]) do 21 | # Drop the previous tenant to reset the data 22 | SQL.query(repo, "DROP SCHEMA \"#{@tenant}\" CASCADE", []) 23 | 24 | opts_with_prefix = Keyword.put(opts, :prefix, @tenant) 25 | 26 | # Create new tenant 27 | SQL.query(repo, "CREATE SCHEMA \"#{@tenant}\"", []) 28 | Ecto.Migrator.run(repo, migrations_path(repo), direction, opts_with_prefix) 29 | end 30 | 31 | def tenant(), do: @tenant 32 | 33 | defp migrations_path(repo), do: Path.join(build_repo_priv(repo), @migrations_path) 34 | 35 | def source_repo_priv(repo) do 36 | repo.config()[:priv] || "priv/#{repo |> Module.split() |> List.last() |> Macro.underscore()}" 37 | end 38 | 39 | def build_repo_priv(repo) do 40 | Application.app_dir(Keyword.fetch!(repo.config(), :otp_app), source_repo_priv(repo)) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/support/repos.ex: -------------------------------------------------------------------------------- 1 | defmodule PaperTrail.Repo do 2 | use Ecto.Repo, otp_app: :paper_trail, adapter: Ecto.Adapters.Postgres 3 | end 4 | 5 | defmodule PaperTrail.UUIDRepo do 6 | use Ecto.Repo, otp_app: :paper_trail, adapter: Ecto.Adapters.Postgres 7 | end 8 | 9 | defmodule PaperTrail.UUIDWithCustomNameRepo do 10 | use Ecto.Repo, otp_app: :paper_trail, adapter: Ecto.Adapters.Postgres 11 | end 12 | 13 | defmodule User do 14 | use Ecto.Schema 15 | 16 | import Ecto.Changeset 17 | 18 | schema "users" do 19 | field(:token, :string) 20 | field(:username, :string) 21 | 22 | timestamps() 23 | end 24 | 25 | def changeset(model, params \\ %{}) do 26 | model 27 | |> cast(params, [:token, :username]) 28 | |> validate_required([:token, :username]) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/support/simple_models.ex: -------------------------------------------------------------------------------- 1 | defmodule LocationType do 2 | use Ecto.Type 3 | 4 | defstruct [:country] 5 | 6 | @impl true 7 | def type, do: :map 8 | 9 | @impl true 10 | def embed_as(_format), do: :dump 11 | 12 | @impl true 13 | def cast(%__MODULE__{} = location), do: {:ok, location} 14 | def cast(%{} = data), do: {:ok, struct!(__MODULE__, data)} 15 | def cast(_), do: :error 16 | 17 | @impl true 18 | def load(data) when is_map(data) do 19 | data = Enum.map(data, fn {key, val} -> {String.to_existing_atom(key), val} end) 20 | 21 | {:ok, struct!(__MODULE__, data)} 22 | end 23 | 24 | @impl true 25 | def dump(%__MODULE__{} = location), do: {:ok, Map.from_struct(location)} 26 | def dump(_), do: :error 27 | end 28 | 29 | defmodule SimpleCompany do 30 | use Ecto.Schema 31 | 32 | alias PaperTrailTest.MultiTenantHelper, as: MultiTenant 33 | 34 | import Ecto.Changeset 35 | import Ecto.Query 36 | 37 | schema "simple_companies" do 38 | field(:name, :string) 39 | field(:is_active, :boolean) 40 | field(:website, :string) 41 | field(:city, :string) 42 | field(:address, :string) 43 | field(:facebook, :string) 44 | field(:twitter, :string) 45 | field(:founded_in, :string) 46 | field(:location, LocationType) 47 | 48 | has_many(:people, SimplePerson, foreign_key: :company_id) 49 | 50 | timestamps() 51 | end 52 | 53 | @optional_fields ~w( 54 | name 55 | is_active 56 | website 57 | city 58 | address 59 | facebook 60 | twitter 61 | founded_in 62 | location 63 | )a 64 | 65 | def changeset(model, params \\ %{}) do 66 | model 67 | |> cast(params, @optional_fields) 68 | |> validate_required([:name]) 69 | |> no_assoc_constraint(:people) 70 | end 71 | 72 | def count do 73 | from(record in __MODULE__, select: count(record.id)) |> PaperTrail.RepoClient.repo().one 74 | end 75 | 76 | def count(:multitenant) do 77 | from(record in __MODULE__, select: count(record.id)) 78 | |> MultiTenant.add_prefix_to_query() 79 | |> PaperTrail.RepoClient.repo().one 80 | end 81 | end 82 | 83 | defmodule SimplePerson do 84 | use Ecto.Schema 85 | 86 | alias PaperTrailTest.MultiTenantHelper, as: MultiTenant 87 | 88 | import Ecto.Changeset 89 | import Ecto.Query 90 | 91 | schema "simple_people" do 92 | field(:first_name, :string) 93 | field(:last_name, :string) 94 | field(:visit_count, :integer) 95 | field(:gender, :boolean) 96 | field(:birthdate, :date) 97 | 98 | belongs_to(:company, SimpleCompany, foreign_key: :company_id) 99 | 100 | embeds_one(:singular, SimpleEmbed, on_replace: :update) 101 | embeds_many(:plural, SimpleEmbed) 102 | 103 | timestamps() 104 | end 105 | 106 | @optional_fields ~w( 107 | first_name 108 | last_name 109 | visit_count 110 | gender 111 | birthdate 112 | company_id 113 | )a 114 | 115 | def changeset(model, params \\ %{}) do 116 | model 117 | |> cast(params, @optional_fields) 118 | |> foreign_key_constraint(:company_id) 119 | |> cast_embed(:singular) 120 | |> cast_embed(:plural) 121 | end 122 | 123 | def count do 124 | from(record in __MODULE__, select: count(record.id)) |> PaperTrail.RepoClient.repo().one 125 | end 126 | 127 | def count(:multitenant) do 128 | from(record in __MODULE__, select: count(record.id)) 129 | |> MultiTenant.add_prefix_to_query() 130 | |> PaperTrail.RepoClient.repo().one 131 | end 132 | end 133 | 134 | defmodule SimpleEmbed do 135 | use Ecto.Schema 136 | 137 | import Ecto.Changeset 138 | 139 | embedded_schema do 140 | field(:name, :string) 141 | end 142 | 143 | def changeset(model, params \\ %{}) do 144 | model 145 | |> cast(params, [:name]) 146 | end 147 | end 148 | -------------------------------------------------------------------------------- /test/support/strict_models.ex: -------------------------------------------------------------------------------- 1 | defmodule StrictCompany do 2 | use Ecto.Schema 3 | 4 | alias PaperTrailTest.MultiTenantHelper, as: MultiTenant 5 | 6 | import Ecto.Changeset 7 | import Ecto.Query 8 | 9 | schema "strict_companies" do 10 | field(:name, :string) 11 | field(:is_active, :boolean) 12 | field(:website, :string) 13 | field(:city, :string) 14 | field(:address, :string) 15 | field(:facebook, :string) 16 | field(:twitter, :string) 17 | field(:founded_in, :string) 18 | 19 | belongs_to(:first_version, PaperTrail.Version) 20 | belongs_to(:current_version, PaperTrail.Version, on_replace: :update) 21 | 22 | has_many(:people, StrictPerson, foreign_key: :company_id) 23 | 24 | timestamps() 25 | end 26 | 27 | @optional_fields ~w(name is_active website city address facebook twitter founded_in)a 28 | 29 | def changeset(model, params \\ %{}) do 30 | model 31 | |> cast(params, @optional_fields) 32 | |> validate_required([:name]) 33 | |> no_assoc_constraint(:people) 34 | end 35 | 36 | def count do 37 | from(record in __MODULE__, select: count(record.id)) |> PaperTrail.RepoClient.repo().one 38 | end 39 | 40 | def count(:multitenant) do 41 | from(record in __MODULE__, select: count(record.id)) 42 | |> MultiTenant.add_prefix_to_query() 43 | |> PaperTrail.RepoClient.repo().one 44 | end 45 | end 46 | 47 | defmodule StrictPerson do 48 | use Ecto.Schema 49 | 50 | alias PaperTrailTest.MultiTenantHelper, as: MultiTenant 51 | 52 | import Ecto.Changeset 53 | import Ecto.Query 54 | 55 | schema "strict_people" do 56 | field(:first_name, :string) 57 | field(:last_name, :string) 58 | field(:visit_count, :integer) 59 | field(:gender, :boolean) 60 | field(:birthdate, :date) 61 | 62 | belongs_to(:first_version, PaperTrail.Version) 63 | belongs_to(:current_version, PaperTrail.Version, on_replace: :update) 64 | belongs_to(:company, StrictCompany, foreign_key: :company_id) 65 | 66 | timestamps() 67 | end 68 | 69 | @optional_fields ~w(first_name last_name visit_count gender birthdate company_id)a 70 | 71 | def changeset(model, params \\ %{}) do 72 | model 73 | |> cast(params, @optional_fields) 74 | |> foreign_key_constraint(:company_id) 75 | end 76 | 77 | def count do 78 | from(record in __MODULE__, select: count(record.id)) |> PaperTrail.RepoClient.repo().one 79 | end 80 | 81 | def count(:multitenant) do 82 | from(record in __MODULE__, select: count(record.id)) 83 | |> MultiTenant.add_prefix_to_query() 84 | |> PaperTrail.RepoClient.repo().one 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /test/support/uuid_models.ex: -------------------------------------------------------------------------------- 1 | defmodule Product do 2 | use Ecto.Schema 3 | 4 | import Ecto.Changeset 5 | 6 | @primary_key {:id, :binary_id, autogenerate: true} 7 | schema "products" do 8 | field(:name, :string) 9 | 10 | timestamps() 11 | end 12 | 13 | def changeset(model, params \\ %{}) do 14 | model 15 | |> cast(params, [:name]) 16 | |> validate_required([:name]) 17 | end 18 | end 19 | 20 | defmodule Admin do 21 | use Ecto.Schema 22 | import Ecto.Changeset 23 | 24 | @primary_key {:id, :binary_id, autogenerate: true} 25 | schema "admins" do 26 | field(:email, :string) 27 | 28 | timestamps() 29 | end 30 | 31 | def changeset(model, params \\ %{}) do 32 | model 33 | |> cast(params, [:email]) 34 | |> validate_required([:email]) 35 | end 36 | end 37 | 38 | defmodule Item do 39 | use Ecto.Schema 40 | import Ecto.Changeset 41 | 42 | @primary_key {:item_id, :binary_id, autogenerate: true} 43 | schema "items" do 44 | field(:title, :string) 45 | 46 | timestamps() 47 | end 48 | 49 | def changeset(model, params \\ %{}) do 50 | model 51 | |> cast(params, [:title]) 52 | |> validate_required(:title) 53 | end 54 | end 55 | 56 | defmodule FooItem do 57 | use Ecto.Schema 58 | import Ecto.Changeset 59 | 60 | @primary_key {:id, :id, autogenerate: true} 61 | schema "foo_items" do 62 | field(:title, :string) 63 | 64 | timestamps() 65 | end 66 | 67 | def changeset(model, params \\ %{}) do 68 | model 69 | |> cast(params, [:title]) 70 | |> validate_required(:title) 71 | end 72 | end 73 | 74 | defmodule BarItem do 75 | use Ecto.Schema 76 | import Ecto.Changeset 77 | 78 | @primary_key {:item_id, :string, autogenerate: false} 79 | schema "bar_items" do 80 | field(:title, :string) 81 | 82 | timestamps() 83 | end 84 | 85 | def changeset(model, params \\ %{}) do 86 | model 87 | |> cast(params, [:item_id, :title]) 88 | |> validate_required([:item_id, :title]) 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /test/support/uuid_with_custom_name_models.ex: -------------------------------------------------------------------------------- 1 | defmodule Project do 2 | use Ecto.Schema 3 | 4 | import Ecto.Changeset 5 | 6 | @primary_key {:uuid, :binary_id, autogenerate: true} 7 | schema "projects" do 8 | field(:name, :string) 9 | 10 | timestamps() 11 | end 12 | 13 | def changeset(model, params \\ %{}) do 14 | model 15 | |> cast(params, [:name]) 16 | |> validate_required([:name]) 17 | end 18 | end 19 | 20 | defmodule Person do 21 | use Ecto.Schema 22 | import Ecto.Changeset 23 | 24 | @primary_key {:uuid, :binary_id, autogenerate: true} 25 | schema "people" do 26 | field(:email, :string) 27 | 28 | timestamps() 29 | end 30 | 31 | def changeset(model, params \\ %{}) do 32 | model 33 | |> cast(params, [:email]) 34 | |> validate_required([:email]) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.start(:postgrex) 2 | 3 | Application.put_env(:paper_trail, :ecto_repos, [ 4 | PaperTrail.Repo, 5 | PaperTrail.UUIDRepo, 6 | PaperTrail.UUIDWithCustomNameRepo 7 | ]) 8 | 9 | Application.put_env(:paper_trail, :repo, PaperTrail.Repo) 10 | Application.put_env(:paper_trail, :originator, name: :user, model: User) 11 | 12 | Mix.Task.run("ecto.drop") 13 | Mix.Task.run("ecto.create") 14 | Mix.Task.run("ecto.migrate") 15 | 16 | PaperTrail.Repo.start_link() 17 | PaperTrail.UUIDRepo.start_link() 18 | PaperTrail.UUIDWithCustomNameRepo.start_link() 19 | 20 | ExUnit.configure(seed: 0) 21 | 22 | ExUnit.start(capture_log: true) 23 | -------------------------------------------------------------------------------- /test/uuid/uuid_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrailTest.UUIDTest do 2 | use ExUnit.Case 3 | import PaperTrail.RepoClient, only: [repo: 0] 4 | alias PaperTrail.Version 5 | import Ecto.Query 6 | 7 | setup_all do 8 | Application.put_env(:paper_trail, :repo, PaperTrail.UUIDRepo) 9 | Application.put_env(:paper_trail, :originator, name: :admin, model: Admin) 10 | Application.put_env(:paper_trail, :originator_type, Ecto.UUID) 11 | 12 | Application.put_env( 13 | :paper_trail, 14 | :item_type, 15 | if(System.get_env("STRING_TEST") == nil, do: Ecto.UUID, else: :string) 16 | ) 17 | 18 | Code.eval_file("lib/paper_trail.ex") 19 | Code.eval_file("lib/version.ex") 20 | 21 | repo().delete_all(Version) 22 | repo().delete_all(Admin) 23 | repo().delete_all(Product) 24 | repo().delete_all(Item) 25 | :ok 26 | end 27 | 28 | describe "PaperTrailTest.UUIDTest" do 29 | test "creates versions with models that have a UUID primary key" do 30 | product = 31 | %Product{} 32 | |> Product.changeset(%{name: "Hair Cream"}) 33 | |> PaperTrail.insert!() 34 | 35 | version = Version |> last |> repo().one 36 | 37 | assert version.item_id == product.id 38 | assert version.item_type == "Product" 39 | end 40 | 41 | test "handles originators with a UUID primary key" do 42 | admin = 43 | %Admin{} 44 | |> Admin.changeset(%{email: "admin@example.com"}) 45 | |> repo().insert! 46 | 47 | %Product{} 48 | |> Product.changeset(%{name: "Hair Cream"}) 49 | |> PaperTrail.insert!(originator: admin) 50 | 51 | version = 52 | Version 53 | |> last 54 | |> repo().one 55 | |> repo().preload(:admin) 56 | 57 | assert version.admin == admin 58 | end 59 | 60 | test "versioning models that have a non-regular primary key" do 61 | item = 62 | %Item{} 63 | |> Item.changeset(%{title: "hello"}) 64 | |> PaperTrail.insert!() 65 | 66 | version = Version |> last |> repo().one 67 | assert version.item_id == item.item_id 68 | end 69 | 70 | test "test INTEGER primary key for item_type == :string" do 71 | if PaperTrail.Version.__schema__(:type, :item_id) == :string do 72 | item = 73 | %FooItem{} 74 | |> FooItem.changeset(%{title: "hello"}) 75 | |> PaperTrail.insert!() 76 | 77 | version = Version |> last |> repo().one 78 | assert version.item_id == "#{item.id}" 79 | end 80 | end 81 | 82 | test "test STRING primary key for item_type == :string" do 83 | if PaperTrail.Version.__schema__(:type, :item_id) == :string do 84 | item = 85 | %BarItem{} 86 | |> BarItem.changeset(%{item_id: "#{:os.system_time()}", title: "hello"}) 87 | |> PaperTrail.insert!() 88 | 89 | version = Version |> last |> repo().one 90 | assert version.item_id == item.item_id 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /test/uuid/uuid_with_custom_name_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrailTest.UUIDWithCustomNameTest do 2 | use ExUnit.Case 3 | import PaperTrail.RepoClient, only: [repo: 0] 4 | alias PaperTrail.Version 5 | import Ecto.Query 6 | 7 | setup_all do 8 | Application.put_env(:paper_trail, :repo, PaperTrail.UUIDWithCustomNameRepo) 9 | Application.put_env(:paper_trail, :originator, name: :originator, model: Person) 10 | Application.put_env(:paper_trail, :originator_type, Ecto.UUID) 11 | Application.put_env(:paper_trail, :originator_relationship_options, references: :uuid) 12 | 13 | Application.put_env( 14 | :paper_trail, 15 | :item_type, 16 | if(System.get_env("STRING_TEST") == nil, do: Ecto.UUID, else: :string) 17 | ) 18 | 19 | Code.eval_file("lib/paper_trail.ex") 20 | Code.eval_file("lib/version.ex") 21 | 22 | repo().delete_all(Version) 23 | repo().delete_all(Person) 24 | repo().delete_all(Project) 25 | :ok 26 | end 27 | 28 | describe "PaperTrailTest.UUIDWithCustomNameTest" do 29 | test "handles originators with a UUID primary key" do 30 | person = 31 | %Person{} 32 | |> Person.changeset(%{email: "admin@example.com"}) 33 | |> repo().insert! 34 | 35 | %Project{} 36 | |> Project.changeset(%{name: "Interesting Stuff"}) 37 | |> PaperTrail.insert!(originator: person) 38 | 39 | version = 40 | Version 41 | |> last 42 | |> repo().one 43 | |> repo().preload(:originator) 44 | 45 | assert version.originator == person 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/version/paper_trail_version_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrailTest.Version do 2 | use ExUnit.Case 3 | 4 | alias PaperTrail.Version 5 | alias PaperTrailTest.MultiTenantHelper, as: MultiTenant 6 | alias PaperTrail.RepoClient 7 | alias PaperTrail.Serializer 8 | 9 | @valid_attrs %{ 10 | event: "insert", 11 | item_type: "Person", 12 | item_id: 1, 13 | item_changes: %{first_name: "Izel", last_name: "Nakri"}, 14 | origin: "test", 15 | inserted_at: DateTime.from_naive!(~N[1952-04-01 01:00:00], "Etc/UTC") 16 | } 17 | @invalid_attrs %{} 18 | 19 | defdelegate repo, to: RepoClient 20 | defdelegate serialize(data), to: Serializer 21 | 22 | setup_all do 23 | Application.put_env(:paper_trail, :strict_mode, false) 24 | Application.put_env(:paper_trail, :repo, PaperTrail.Repo) 25 | Application.put_env(:paper_trail, :originator_type, :integer) 26 | 27 | MultiTenant.setup_tenant(repo()) 28 | :ok 29 | end 30 | 31 | setup do 32 | repo().delete_all(Version) 33 | 34 | Version 35 | |> MultiTenant.add_prefix_to_query() 36 | |> repo().delete_all() 37 | 38 | on_exit(fn -> 39 | repo().delete_all(Version) 40 | 41 | Version 42 | |> MultiTenant.add_prefix_to_query() 43 | |> repo().delete_all() 44 | end) 45 | 46 | :ok 47 | end 48 | 49 | test "changeset with valid attributes" do 50 | changeset = Version.changeset(%Version{event: "insert"}, @valid_attrs) 51 | assert changeset.valid? 52 | end 53 | 54 | test "changeset without invalid attributes" do 55 | changeset = Version.changeset(%Version{event: "insert"}, @invalid_attrs) 56 | refute changeset.valid? 57 | end 58 | 59 | test "count works" do 60 | versions = add_three_versions() 61 | assert Version.count() == length(versions) 62 | end 63 | 64 | test "first works" do 65 | add_three_versions() 66 | 67 | target_model = 68 | @valid_attrs 69 | |> Map.delete(:inserted_at) 70 | |> Map.merge(%{ 71 | item_changes: %{"first_name" => "Izel", "last_name" => "Nakri"} 72 | }) 73 | 74 | target_version = 75 | Version.first() 76 | |> serialize 77 | |> Map.drop([ 78 | :id, 79 | :meta, 80 | :originator_id, 81 | :inserted_at 82 | ]) 83 | 84 | assert target_version == target_model 85 | end 86 | 87 | test "last works" do 88 | add_three_versions() 89 | 90 | assert Version.last() |> serialize != %{ 91 | event: "insert", 92 | item_type: "Person", 93 | item_id: 3, 94 | item_changes: %{first_name: "Yukihiro", last_name: "Matsumoto"}, 95 | origin: "test", 96 | inserted_at: DateTime.from_naive!(~N[1965-04-14 01:00:00], "Etc/UTC") 97 | } 98 | end 99 | 100 | # Multi tenant tests 101 | test "[multi tenant] count works" do 102 | versions = add_three_versions(MultiTenant.tenant()) 103 | assert Version.count(prefix: MultiTenant.tenant()) == length(versions) 104 | assert Version.count() != length(versions) 105 | end 106 | 107 | test "[multi tenant] first works" do 108 | add_three_versions(MultiTenant.tenant()) 109 | 110 | target_version = 111 | Version.first(prefix: MultiTenant.tenant()) 112 | |> serialize 113 | |> Map.drop([ 114 | :id, 115 | :meta, 116 | :originator_id, 117 | :inserted_at 118 | ]) 119 | 120 | target_model = 121 | @valid_attrs 122 | |> Map.delete(:inserted_at) 123 | |> Map.merge(%{ 124 | item_changes: %{"first_name" => "Izel", "last_name" => "Nakri"} 125 | }) 126 | 127 | assert target_version == target_model 128 | assert Version.first() == nil 129 | end 130 | 131 | test "[multi tenant] last works" do 132 | add_three_versions(MultiTenant.tenant()) 133 | 134 | assert Version.last(prefix: MultiTenant.tenant()) |> serialize != %{ 135 | event: "insert", 136 | item_type: "Person", 137 | item_id: 3, 138 | item_changes: %{first_name: "Yukihiro", last_name: "Matsumoto"}, 139 | origin: "test", 140 | inserted_at: DateTime.from_naive!(~N[1965-04-14 01:00:00], "Etc/UTC") 141 | } 142 | 143 | assert Version.last() == nil 144 | end 145 | 146 | def add_three_versions(prefix \\ nil) do 147 | repo().insert_all( 148 | Version, 149 | [ 150 | @valid_attrs, 151 | %{ 152 | event: "insert", 153 | item_type: "Person", 154 | item_id: 2, 155 | item_changes: %{first_name: "Brendan", last_name: "Eich"}, 156 | origin: "test", 157 | inserted_at: DateTime.from_naive!(~N[1961-07-04 01:00:00], "Etc/UTC") 158 | }, 159 | %{ 160 | event: "insert", 161 | item_type: "Person", 162 | item_id: 3, 163 | item_changes: %{first_name: "Yukihiro", last_name: "Matsumoto"}, 164 | origin: "test", 165 | inserted_at: DateTime.from_naive!(~N[1965-04-14 01:00:00], "Etc/UTC") 166 | } 167 | ], 168 | returning: true, 169 | prefix: prefix 170 | ) 171 | |> elem(1) 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /test/version/version_queries_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PaperTrailTest.VersionQueries do 2 | use ExUnit.Case 3 | 4 | alias PaperTrail.Version 5 | alias SimpleCompany, as: Company 6 | alias SimplePerson, as: Person 7 | alias PaperTrailTest.MultiTenantHelper, as: MultiTenant 8 | alias PaperTrail.RepoClient 9 | 10 | import Ecto.Query 11 | 12 | defdelegate repo, to: RepoClient 13 | 14 | setup_all do 15 | Application.put_env(:paper_trail, :repo, PaperTrail.Repo) 16 | Application.put_env(:paper_trail, :originator_type, :integer) 17 | 18 | MultiTenant.setup_tenant(repo()) 19 | reset_all_data() 20 | 21 | Company.changeset(%Company{}, %{ 22 | name: "Acme LLC", 23 | is_active: true, 24 | city: "Greenwich" 25 | }) 26 | |> PaperTrail.insert() 27 | 28 | old_company = first(Company, :id) |> repo().one 29 | 30 | Company.changeset(old_company, %{ 31 | city: "Hong Kong", 32 | website: "http://www.acme.com", 33 | facebook: "acme.llc" 34 | }) 35 | |> PaperTrail.update() 36 | 37 | first(Company, :id) |> repo().one |> PaperTrail.delete() 38 | 39 | Company.changeset(%Company{}, %{ 40 | name: "Acme LLC", 41 | website: "http://www.acme.com" 42 | }) 43 | |> PaperTrail.insert() 44 | 45 | Company.changeset(%Company{}, %{ 46 | name: "Another Company Corp.", 47 | is_active: true, 48 | address: "Sesame street 100/3, 101010" 49 | }) 50 | |> PaperTrail.insert() 51 | 52 | company = first(Company, :id) |> repo().one 53 | 54 | # add link name later on 55 | Person.changeset(%Person{}, %{ 56 | first_name: "Izel", 57 | last_name: "Nakri", 58 | gender: true, 59 | company_id: company.id 60 | }) 61 | |> PaperTrail.insert(set_by: "admin") 62 | 63 | another_company = 64 | repo().one( 65 | from( 66 | c in Company, 67 | where: c.name == "Another Company Corp.", 68 | limit: 1 69 | ) 70 | ) 71 | 72 | Person.changeset(first(Person, :id) |> repo().one, %{ 73 | first_name: "Isaac", 74 | visit_count: 10, 75 | birthdate: ~D[1992-04-01], 76 | company_id: another_company.id 77 | }) 78 | |> PaperTrail.update(set_by: "user:1", meta: %{linkname: "izelnakri"}) 79 | 80 | # Multi tenant 81 | Company.changeset(%Company{}, %{ 82 | name: "Acme LLC", 83 | is_active: true, 84 | city: "Greenwich" 85 | }) 86 | |> MultiTenant.add_prefix_to_changeset() 87 | |> PaperTrail.insert(prefix: MultiTenant.tenant()) 88 | 89 | company_multi = 90 | first(Company, :id) 91 | |> MultiTenant.add_prefix_to_query() 92 | |> repo().one 93 | 94 | Person.changeset(%Person{}, %{ 95 | first_name: "Izel", 96 | last_name: "Nakri", 97 | gender: true, 98 | company_id: company_multi.id 99 | }) 100 | |> MultiTenant.add_prefix_to_changeset() 101 | |> PaperTrail.insert(set_by: "admin", prefix: MultiTenant.tenant()) 102 | 103 | :ok 104 | end 105 | 106 | test "get_version gives us the right version" do 107 | tenant = MultiTenant.tenant() 108 | last_person = last(Person, :id) |> repo().one 109 | target_version = last(Version, :id) |> repo().one 110 | 111 | last_person_multi = 112 | last(Person, :id) 113 | |> MultiTenant.add_prefix_to_query() 114 | |> repo().one 115 | 116 | target_version_multi = 117 | last(Version, :id) 118 | |> MultiTenant.add_prefix_to_query() 119 | |> repo().one 120 | 121 | assert PaperTrail.get_version(last_person) == target_version 122 | assert PaperTrail.get_version(Person, last_person.id) == target_version 123 | assert PaperTrail.get_version(last_person_multi, prefix: tenant) == target_version_multi 124 | 125 | assert PaperTrail.get_version(Person, last_person_multi.id, prefix: tenant) == 126 | target_version_multi 127 | 128 | assert target_version != target_version_multi 129 | end 130 | 131 | test "get_versions gives us the right versions" do 132 | tenant = MultiTenant.tenant() 133 | last_person = last(Person, :id) |> repo().one 134 | 135 | target_versions = 136 | repo().all( 137 | from( 138 | version in Version, 139 | where: version.item_type == "SimplePerson" and version.item_id == ^last_person.id 140 | ) 141 | ) 142 | 143 | last_person_multi = 144 | last(Person, :id) 145 | |> MultiTenant.add_prefix_to_query() 146 | |> repo().one 147 | 148 | target_versions_multi = 149 | from( 150 | version in Version, 151 | where: version.item_type == "SimplePerson" and version.item_id == ^last_person_multi.id 152 | ) 153 | |> MultiTenant.add_prefix_to_query() 154 | |> repo().all 155 | 156 | assert PaperTrail.get_versions(last_person) == target_versions 157 | assert PaperTrail.get_versions(Person, last_person.id) == target_versions 158 | assert PaperTrail.get_versions(last_person_multi, prefix: tenant) == target_versions_multi 159 | 160 | assert PaperTrail.get_versions(Person, last_person_multi.id, prefix: tenant) == 161 | target_versions_multi 162 | 163 | assert target_versions != target_versions_multi 164 | end 165 | 166 | test "get_current_model/1 gives us the current record of a version" do 167 | person = first(Person, :id) |> repo().one 168 | 169 | first_version = 170 | Version 171 | |> where([v], v.item_type == "SimplePerson" and v.item_id == ^person.id) 172 | |> first 173 | |> repo().one 174 | 175 | assert PaperTrail.get_current_model(first_version) == person 176 | end 177 | 178 | # query meta data!! 179 | 180 | # Functions 181 | defp reset_all_data() do 182 | repo().delete_all(Person) 183 | repo().delete_all(Company) 184 | repo().delete_all(Version) 185 | 186 | Person 187 | |> MultiTenant.add_prefix_to_query() 188 | |> repo().delete_all() 189 | 190 | Company 191 | |> MultiTenant.add_prefix_to_query() 192 | |> repo().delete_all() 193 | 194 | Version 195 | |> MultiTenant.add_prefix_to_query() 196 | |> repo().delete_all() 197 | end 198 | end 199 | --------------------------------------------------------------------------------