├── config ├── config.exs └── test.exs ├── .formatter.exs ├── test ├── test_helper.exs ├── support │ ├── post.ex │ ├── repo │ │ ├── myxql.ex │ │ └── postgres.ex │ ├── article.ex │ └── post_comment.ex └── ecto_cellar_test.exs ├── priv └── repo │ └── migrations │ ├── 20220316134354_create_posts.exs │ ├── 20220329132501_create_post_comments.exs │ └── 20220316140555_create_articles.exs ├── Makefile ├── support └── repo │ └── post.ex ├── docker-compose.yml ├── lib ├── tasks │ └── gen.ex ├── ecto_cellar │ └── version.ex └── ecto_cellar.ex ├── .gitignore ├── CHANGELOG.md ├── mix.exs ├── .github └── workflows │ └── ci.yml ├── README.md ├── mix.lock └── LICENSE.md /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if Mix.env() == :test do 4 | import_config("test.exs") 5 | end 6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | 3 | if System.get_env("DB_ADAPTER") == "mysql" do 4 | MyXQL.Repo.start_link() 5 | else 6 | Application.ensure_all_started(:postgrex) 7 | Postgres.Repo.start_link() 8 | end 9 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220316134354_create_posts.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.CreatePosts do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:posts) do 6 | add :title, :string 7 | add :views, :integer 8 | 9 | timestamps() 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | up: 2 | docker-compose up 3 | 4 | down: 5 | docker-compose down 6 | 7 | setup: 8 | MIX_ENV=test mix ecto_cellar.gen && MIX_ENV=test mix ecto.create && MIX_ENV=test mix ecto.migrate 9 | 10 | setup_mysql: 11 | MIX_ENV=test DB_ADAPTER=mysql mix ecto.create && MIX_ENV=test DB_ADAPTER=mysql mix ecto.migrate -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if System.get_env("DB_ADAPTER") == "mysql" do 4 | config :ecto_cellar, :default_repo, MyXQL.Repo 5 | config :ecto_cellar, ecto_repos: [MyXQL.Repo] 6 | else 7 | config :ecto_cellar, :default_repo, Postgres.Repo 8 | config :ecto_cellar, ecto_repos: [Postgres.Repo] 9 | end 10 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220329132501_create_post_comments.exs: -------------------------------------------------------------------------------- 1 | defmodule Postgres.Repo.Migrations.CreatePostComments do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:post_comments) do 6 | add :content, :string 7 | add :post_id, references(:posts) 8 | 9 | timestamps() 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /priv/repo/migrations/20220316140555_create_articles.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.CreateArticles do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:articles, primary_key: false) do 6 | add :uuid, :string, primary_key: true, null: false 7 | add :title, :string 8 | add :views, :integer 9 | 10 | timestamps() 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /support/repo/post.ex: -------------------------------------------------------------------------------- 1 | defmodule Works.Post do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "blog_posts" do 6 | field :title, :string 7 | field :views, :integer 8 | 9 | timestamps() 10 | end 11 | 12 | @doc false 13 | def changeset(post, attrs) do 14 | post 15 | |> cast(attrs, [:title, :views]) 16 | |> validate_required([:title, :views]) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/support/post.ex: -------------------------------------------------------------------------------- 1 | defmodule Post do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "posts" do 6 | field(:title, :string) 7 | field(:views, :integer) 8 | has_many(:comments, PostComment) 9 | timestamps() 10 | end 11 | 12 | def changeset(post, attrs) do 13 | post 14 | |> cast(attrs, [:title, :views]) 15 | |> validate_required([:title, :views]) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/support/repo/myxql.ex: -------------------------------------------------------------------------------- 1 | defmodule MyXQL.Repo do 2 | use Ecto.Repo, 3 | otp_app: :ecto_cellar, 4 | adapter: Ecto.Adapters.MyXQL 5 | 6 | def init(_, opts) do 7 | {:ok, 8 | Keyword.merge(opts, 9 | username: "root", 10 | password: "mysql-root", 11 | database: "ecto_cellar_mysql_test", 12 | hostname: "localhost", 13 | pool: Ecto.Adapters.SQL.Sandbox 14 | )} 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/support/article.ex: -------------------------------------------------------------------------------- 1 | defmodule Article do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | @primary_key {:uuid, :string, []} 5 | 6 | schema "articles" do 7 | field(:title, :string) 8 | field(:views, :integer) 9 | 10 | timestamps() 11 | end 12 | 13 | def changeset(article, attrs) do 14 | article 15 | |> cast(attrs, [:title, :views]) 16 | |> validate_required([:title, :views]) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /test/support/post_comment.ex: -------------------------------------------------------------------------------- 1 | defmodule PostComment do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | schema "post_comments" do 6 | field(:content, :string) 7 | field(:virtual, :string, virtual: true) 8 | 9 | belongs_to(:post, Post) 10 | 11 | timestamps() 12 | end 13 | 14 | def changeset(post, attrs) do 15 | post 16 | |> cast(attrs, [:content]) 17 | |> validate_required([:content, :post_id]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/support/repo/postgres.ex: -------------------------------------------------------------------------------- 1 | defmodule Postgres.Repo do 2 | use Ecto.Repo, 3 | otp_app: :ecto_cellar, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | def init(_, opts) do 7 | {:ok, 8 | Keyword.merge(opts, 9 | username: "postgres", 10 | password: "postgres", 11 | database: "ecto_cellar_postgres_test", 12 | hostname: "localhost", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | show_sensitive_data_on_connection_error: true, 15 | pool_size: 10 16 | )} 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | services: 3 | postgresql: 4 | platform: linux/x86_64 5 | image: postgres:13.4 6 | container_name: postgresql 7 | ports: 8 | - 5432:5432 9 | environment: 10 | POSTGRES_USER: postgres 11 | POSTGRES_PASSWORD: postgres 12 | POSTGRES_INITDB_ARGS: "--encoding=UTF-8" 13 | hostname: postgres 14 | restart: always 15 | user: root 16 | mysql: 17 | platform: linux/x86_64 18 | image: mysql:5.7 19 | container_name: mysql 20 | ports: 21 | - 3306:3306 22 | environment: 23 | MYSQL_ROOT_PASSWORD: mysql-root 24 | -------------------------------------------------------------------------------- /lib/tasks/gen.ex: -------------------------------------------------------------------------------- 1 | defmodule Mix.Tasks.EctoCellar.Gen do 2 | use Mix.Task 3 | @shortdoc "Generates a new migration file for EctoCellar" 4 | 5 | def run(opts \\ []) do 6 | change = """ 7 | 8 | create table(:versions) do 9 | add :model_id, :string, null: false 10 | add :model_name, :string, null: false 11 | add :model_inserted_at, :naive_datetime, null: false 12 | add :version, :text, null: false 13 | 14 | timestamps() 15 | end 16 | 17 | create index(:versions, [:model_name, :model_id]) 18 | """ 19 | 20 | Mix.Tasks.Ecto.Gen.Migration.run( 21 | ["-r", to_string(EctoCellar.repo()), "create_version_tables", "--change", change] ++ opts 22 | ) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.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 | ecto_cellar-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | /data/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## v0.4.0 4 | 5 | - [Add `insert_store/3`, `update_store/3` and `upsert_store/3`](https://github.com/tashirosota/ecto_cellar/pull/11) 6 | - [Add `delete_store/3`](https://github.com/tashirosota/ecto_cellar/pull/14) 7 | - [Can get primary key automatically.](https://github.com/tashirosota/ecto_cellar/pull/11/commits/3228d2a1f12a3f2b566ac5863b433fc7f0c78bb3) 8 | 9 | ## v0.3.0 10 | 11 | - [Only store non-virtual schema fields in version.](https://github.com/tashirosota/ecto_cellar/pull/9) by [@03juan](https://github.com/03juan) 12 | - [Change last arg to opts](https://github.com/tashirosota/ecto_cellar/pull/10) 13 | - Can select repo when stored. 14 | - Can select primary key other than id too. 15 | - Rename repo to default_repo at config. 16 | 17 | ## v0.2.0 18 | 19 | - Some small fixes. 20 | 21 | ## v0.1.0 22 | 23 | - Initial release. 24 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoCellar.MixProject do 2 | use Mix.Project 3 | @source_url "https://github.com/tashirosota/ecto_cellar" 4 | @description "Store changes to your models, for auditing or versioning." 5 | 6 | def project do 7 | [ 8 | app: :ecto_cellar, 9 | version: "0.4.0", 10 | elixir: "~> 1.10", 11 | description: @description, 12 | name: "EctoCellar", 13 | start_permanent: Mix.env() == :prod, 14 | package: package(), 15 | docs: docs(), 16 | elixirc_paths: elixirc_paths(Mix.env()), 17 | deps: deps(), 18 | dialyzer: [plt_add_apps: [:mix]] 19 | ] 20 | end 21 | 22 | def application do 23 | [ 24 | extra_applications: [:logger] 25 | ] 26 | end 27 | 28 | defp package() do 29 | [ 30 | licenses: ["Apache-2.0"], 31 | maintainers: ["Sota Tashiro"], 32 | links: %{"GitHub" => @source_url} 33 | ] 34 | end 35 | 36 | defp docs do 37 | [ 38 | main: "readme", 39 | extras: ["README.md"] 40 | ] 41 | end 42 | 43 | defp deps do 44 | [ 45 | {:ecto_sql, "~> 3.0"}, 46 | {:ecto, "~> 3.0"}, 47 | {:jason, "~> 1.0"}, 48 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 49 | {:dialyxir, "~> 1.0", only: [:dev, :test], runtime: false}, 50 | {:postgrex, "~> 0.15.0 or ~> 1.0", only: [:dev, :test], optional: true}, 51 | {:myxql, "~> 0.4.0 or ~> 0.5.0", only: [:dev, :test], optional: true} 52 | ] 53 | end 54 | 55 | defp elixirc_paths(:test) do 56 | ["lib", "test/support"] 57 | end 58 | 59 | defp elixirc_paths(_), do: ["lib"] 60 | end 61 | -------------------------------------------------------------------------------- /lib/ecto_cellar/version.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCellar.Version do 2 | @moduledoc false 3 | use Ecto.Schema 4 | import Ecto.Changeset 5 | import Ecto.Query 6 | 7 | @required_fields ~w(model_name model_id model_inserted_at version)a 8 | 9 | schema "versions" do 10 | field(:model_id, :string) 11 | field(:model_name, :string) 12 | field(:model_inserted_at, :naive_datetime) 13 | field(:version, :string) 14 | timestamps() 15 | end 16 | 17 | @type version :: %__MODULE__{} 18 | 19 | @spec create(map(), module()) :: {:ok, version} | {:error, term()} 20 | def create(attr, repo) do 21 | %__MODULE__{} 22 | |> changeset(attr) 23 | |> repo.insert 24 | end 25 | 26 | @spec create!(map(), module()) :: {:ok, version} 27 | def create!(attr, repo) do 28 | %__MODULE__{} 29 | |> changeset(attr) 30 | |> repo.insert! 31 | end 32 | 33 | @spec all(String.t(), String.t(), module()) :: list(version) 34 | def all(model_name, model_id, repo) do 35 | __MODULE__ 36 | |> where(model_name: ^model_name) 37 | |> where(model_id: ^model_id) 38 | |> repo.all() 39 | end 40 | 41 | @spec one(String.t(), NaiveDateTime.t(), String.t(), module()) :: version 42 | def one(model_name, model_inserted_at, model_id, repo) do 43 | __MODULE__ 44 | |> where(model_name: ^model_name) 45 | |> where(model_inserted_at: ^model_inserted_at) 46 | |> where(model_id: ^model_id) 47 | |> repo.one() 48 | end 49 | 50 | defp changeset(model, params) do 51 | model 52 | |> cast(params, @required_fields) 53 | |> validate_required(@required_fields) 54 | |> unique_constraint(:id, name: :PRIMARY) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | jobs: 4 | test_postgres: 5 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 6 | strategy: 7 | matrix: 8 | otp: ['23.3.4.10', '24.3'] 9 | elixir: ['1.10.3', '1.13.3'] 10 | runs-on: ubuntu-latest 11 | env: 12 | MIX_ENV: test 13 | DB_ADAPTER: postgres 14 | services: 15 | postgres: 16 | image: postgres:13.4 17 | env: 18 | POSTGRES_PASSWORD: postgres 19 | POSTGRES_USER: postgres 20 | POSTGRES_DB: postgres 21 | options: >- 22 | --health-cmd pg_isready 23 | --health-interval 10s 24 | --health-timeout 5s 25 | --health-retries 5 26 | ports: 27 | - 5432:5432 28 | steps: 29 | - uses: erlef/setup-elixir@v1 30 | with: 31 | otp-version: ${{matrix.otp}} 32 | elixir-version: ${{matrix.elixir}} 33 | - uses: actions/checkout@v2 34 | - run: mix deps.get 35 | - run: mix compile --warnings-as-errors 36 | - run: mix dialyzer 37 | if: matrix.elixir == '1.13.3' 38 | - run: mix format --check-formatted 39 | if: matrix.elixir == '1.13.3' 40 | - run: mix ecto_cellar.gen 41 | - run: mix ecto.create 42 | - run: mix ecto.migrate 43 | - run: mix test 44 | 45 | test_mysql: 46 | name: OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 47 | strategy: 48 | matrix: 49 | otp: ['23.3.4.10', '24.3'] 50 | elixir: ['1.10.3', '1.13.3'] 51 | runs-on: ubuntu-latest 52 | env: 53 | MIX_ENV: test 54 | DB_ADAPTER: mysql 55 | services: 56 | mysql: 57 | image: mysql:5.7 58 | env: 59 | MYSQL_ROOT_PASSWORD: mysql-root 60 | ports: 61 | - 3306:3306 62 | steps: 63 | - uses: erlef/setup-elixir@v1 64 | with: 65 | otp-version: ${{matrix.otp}} 66 | elixir-version: ${{matrix.elixir}} 67 | - uses: actions/checkout@v2 68 | - run: mix deps.get 69 | - run: mix compile 70 | - run: mix dialyzer 71 | - run: mix ecto_cellar.gen 72 | - run: mix ecto.create 73 | - run: mix ecto.migrate 74 | - run: mix format --check-formatted 75 | if: matrix.elixir == '1.13.3' 76 | - run: mix test -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | [![hex.pm version](https://img.shields.io/hexpm/v/ecto_cellar.svg)](https://hex.pm/packages/ecto_cellar) 4 | [![CI](https://github.com/tashirosota/ecto_cellar/actions/workflows/ci.yml/badge.svg)](https://github.com/tashirosota/ecto_cellar/actions/workflows/ci.yml) 5 | ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/tashirosota/ecto_cellar) 6 | 7 | # EctoCellar 8 | 9 | **Store changes to your models, for auditing or versioning.** 10 | Inspired by [paper_trail](https://github.com/paper-trail-gem/paper_trail). 11 | 12 | ## Documentation 13 | 14 | This is the user guide. See also, the [API reference](https://hexdocs.pm/ecto_cellar). 15 | 16 | ## Installation 17 | 18 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 19 | by adding `ecto_cellar` to your list of dependencies in `mix.exs`: 20 | 21 | ```elixir 22 | def deps do 23 | [ 24 | {:ecto_cellar, "~> 0.3"} 25 | ] 26 | end 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### 1. Configuration. 32 | 33 | Add ecto_cellar configure to your config.exs. 34 | 35 | ```elixir 36 | config :ecto_cellar, :default_repo, YourApp.Repo 37 | ``` 38 | 39 | ### 2. Creates versions table. 40 | 41 | You can generate migration file for EctoCeller. 42 | Let's type `mix ecto_cellar.gen`. 43 | And migrate by `mix ecto.migrate`. 44 | 45 | ### 3. Stores changes to model. 46 | 47 | Stores after a model is changed or created. 48 | These are stored as recoverable versions for the versions table. 49 | 50 | **By `Repo.insert` + `EctoCellar.store/2`.** 51 | 52 | ```elixir 53 | iex> with {:ok, post} <- %Post{title: "title", views: 0} |> @repo.insert(), 54 | ...> {:ok, _post} <- EctoCellar.store(post) do # or store!/2 55 | ...> # do something 56 | ...> end 57 | ``` 58 | 59 | or 60 | 61 | There is also a function that wraps `EctoRepo.insert`, `update`, `insert_or_update` and `delete`. 62 | 63 | **By `EctoCellar.insert_store/3`.** 64 | (Uses `EctoCellar.update_store/3` when updated.) 65 | 66 | ```elixir 67 | iex> case EctoCellar.insert_store(post) do # or update_store/3, upsert_store/3, delete_store/3 68 | ...> {:ok, _post} -> # do_somesing 69 | ...> error -> error 70 | ...> end 71 | ``` 72 | 73 | ### 4. Gets versions and can restore it. 74 | 75 | Uses `EctoCellar.all/2` and `EctoCellar.one/3`, you can get past changes versions. 76 | And use it, you can restore. 77 | 78 | ```elixir 79 | iex> post = Post.find(id) 80 | %Post{id: 1, body: "body3"...etc} 81 | 82 | iex> post_versions = EctoCellar.all(post) # Can get all versions. 83 | [%Post{id: 1, body: "body3"...etc}, %Post{id: 1, body: "body2"...etc}, %Post{id: 1, body: "body1"...etc}] 84 | 85 | iex> version_1_inserted_at = ~N[2022-12-12 12:00:12] 86 | iex> post_version1 = EctoCellar.one(post, version_1_inserted_at) 87 | %Post{id: 1, body: "body1", inserted_at: ~N[2022-12-12 12:00:12]...etc} 88 | 89 | iex> post_version1 90 | iex> |> Post.changeset([]) 91 | iex> |> Repo.update() # Restored!!! 92 | ``` 93 | 94 | ### Options 95 | 96 | **The last argument of each function accepts the following options.** 97 | 98 | - repo: You can select a repo other than the one specified in Config. 99 | 100 | ## For contributers 101 | 102 | You can test locally in these steps. 103 | 104 | 1. `make setup` 105 | 2. `mix test` 106 | 107 | ## Bugs and Feature requests 108 | 109 | Feel free to open an issues or a PR to contribute to the project. 110 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 3 | "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, 4 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 5 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.20", "89970db71b11b6b89759ce16807e857df154f8df3e807b2920a8c39834a9e5cf", [:mix], [], "hexpm", "1eb0d2dabeeeff200e0d17dc3048a6045aab271f73ebb82e416464832eb57bdd"}, 7 | "ecto": {:hex, :ecto, "3.7.1", "a20598862351b29f80f285b21ec5297da1181c0442687f9b8329f0445d228892", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d36e5b39fc479e654cffd4dbe1865d9716e4a9b6311faff799b6f90ab81b8638"}, 8 | "ecto_sql": {:hex, :ecto_sql, "3.7.2", "55c60aa3a06168912abf145c6df38b0295c34118c3624cf7a6977cd6ce043081", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0 or ~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c218ea62f305dcaef0b915fb56583195e7b91c91dcfb006ba1f669bfacbff2a"}, 9 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 10 | "ex_doc": {:hex, :ex_doc, "0.28.2", "e031c7d1a9fc40959da7bf89e2dc269ddc5de631f9bd0e326cbddf7d8085a9da", [:mix], [{:earmark_parser, "~> 1.4.19", [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", "51ee866993ffbd0e41c084a7677c570d0fc50cb85c6b5e76f8d936d9587fa719"}, 11 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 12 | "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"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 15 | "myxql": {:hex, :myxql, "0.5.2", "6c054789112d4d1f626462c771b3aa74bb0fe56012e5a2ef04e629ea96a9088d", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "7bf09e80dde8887cfcb314604b0f39463da91cac6f12af2c4f1553411fa6ee8d"}, 16 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 17 | "postgrex": {:hex, :postgrex, "0.15.13", "7794e697481799aee8982688c261901de493eb64451feee6ea58207d7266d54a", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "3ffb76e1a97cfefe5c6a95632a27ffb67f28871c9741fb585f9d1c3cd2af70f1"}, 18 | "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, 19 | } 20 | -------------------------------------------------------------------------------- /lib/ecto_cellar.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoCellar do 2 | @moduledoc """ 3 | Core module for EctoCellar. 4 | Handles versions table created by `mix ecto_cellar.gen`. 5 | You can use this module to store in the cellar and restore the version. 6 | ## Options 7 | - repo: You can select a repo other than the one specified in Config. 8 | """ 9 | 10 | alias EctoCellar.Version 11 | alias Ecto.Multi 12 | @native_datetime_prefix "ecto_cellar_native_datetime_" 13 | @type options :: Keyword.t() 14 | 15 | @doc """ 16 | Stores the changes at that time in the cellar. 17 | """ 18 | @spec store(Ecto.Schema.t() | Ecto.Changeset.t(), options) :: 19 | {:ok, Ecto.Schema.t()} | {:error, term()} 20 | def store(%mod{} = model, opts \\ []) do 21 | Version.create( 22 | %{ 23 | model_name: mod |> inspect(), 24 | model_id: model_id(model), 25 | model_inserted_at: model.inserted_at, 26 | version: model |> cast_format_map |> Jason.encode!() 27 | }, 28 | repo(opts) 29 | ) 30 | |> case do 31 | {:ok, _version} -> {:ok, model} 32 | error -> error 33 | end 34 | end 35 | 36 | @doc """ 37 | Like store/2, except that if the record is invalid, raises an exception. 38 | """ 39 | @spec store!(Ecto.Schema.t() | Ecto.Changeset.t(), options) :: Ecto.Schema.t() 40 | def store!(%mod{} = model, opts \\ []) do 41 | Version.create!( 42 | %{ 43 | model_name: mod |> inspect(), 44 | model_id: model_id(model), 45 | model_inserted_at: model.inserted_at, 46 | version: model |> cast_format_map |> Jason.encode!() 47 | }, 48 | repo(opts) 49 | ) 50 | 51 | model 52 | end 53 | 54 | @doc """ 55 | Inserts given model(or changeset) and stores the changes at that time in the cellar. 56 | - options: EctoCellar.options() 57 | - insert_opts: options for Ecto.Repo.insert/2 58 | """ 59 | @spec insert_store(Ecto.Schema.t() | Ecto.Changeset.t(), options, Keyword.t()) :: 60 | {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} 61 | def insert_store(changeset, opts \\ [], insert_opts \\ []), 62 | do: do_wrap_func(changeset, opts, insert_opts, :insert) 63 | 64 | @doc """ 65 | Updates given changeset and stores the changes at that time in the cellar. 66 | - options: EctoCellar.options() 67 | - update_opts: options for Ecto.Repo.update/2 68 | """ 69 | @spec update_store(Ecto.Changeset.t(), options, Keyword.t()) :: 70 | {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} 71 | def update_store(changeset, opts \\ [], update_opts \\ []), 72 | do: do_wrap_func(changeset, opts, update_opts, :update) 73 | 74 | @doc """ 75 | Inserts or updates given changeset and stores the changes at that time in the cellar. 76 | - options: EctoCellar.options() 77 | - insert_or_update_opts: options for Ecto.Repo.insert_or_update/2 78 | """ 79 | @spec upsert_store(Ecto.Changeset.t(), options, Keyword.t()) :: 80 | {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} 81 | def upsert_store(changeset, opts \\ [], insert_or_update_opts \\ []), 82 | do: do_wrap_func(changeset, opts, insert_or_update_opts, :insert_or_update) 83 | 84 | @doc """ 85 | Deletes given changeset and stores the changes at that time in the cellar. 86 | - options: EctoCellar.options() 87 | - delete_opts: options for Ecto.Repo.update/2 88 | """ 89 | @spec delete_store(Ecto.Schema.t() | Ecto.Changeset.t(), options, Keyword.t()) :: 90 | {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} 91 | def delete_store(changeset, opts \\ [], delete_opts \\ []), 92 | do: do_wrap_func(changeset, opts, delete_opts, :delete) 93 | 94 | defp do_wrap_func(changeset, celler_opts, ecto_opts, func_atom) do 95 | Multi.new() 96 | |> Multi.run(:schema, fn _repo, _ -> 97 | apply(repo(celler_opts), func_atom, [changeset, ecto_opts]) 98 | end) 99 | |> Multi.run(:store, fn _repo, %{schema: schema} -> store(schema, celler_opts) end) 100 | |> repo(celler_opts).transaction() 101 | |> case do 102 | {:ok, %{schema: schema}} -> 103 | {:ok, schema} 104 | 105 | error -> 106 | error 107 | end 108 | end 109 | 110 | @doc """ 111 | Returns a specific version of model from the cellar. 112 | """ 113 | @spec one(struct(), NaiveDateTime.t(), options) :: Ecto.Schema.t() 114 | def one(%mod{} = model, timestamp, opts \\ []) do 115 | Version.one( 116 | mod |> inspect(), 117 | timestamp, 118 | model_id(model), 119 | repo(opts) 120 | ) 121 | |> to_model(mod) 122 | end 123 | 124 | @doc """ 125 | Returns all versions of model from the cellar. 126 | """ 127 | @spec all(struct(), options) :: [Ecto.Schema.t()] 128 | def all(%mod{} = model, opts \\ []) do 129 | Version.all( 130 | mod |> inspect(), 131 | model_id(model), 132 | repo(opts) 133 | ) 134 | |> to_models(mod) 135 | end 136 | 137 | @doc false 138 | def repo, 139 | do: 140 | Application.get_env(:ecto_cellar, :default_repo) || Application.get_env(:ecto_cellar, :repo) 141 | 142 | defp primary_key(%{__meta__: %{schema: schema}}) do 143 | primary_keyes = schema.__schema__(:primary_key) 144 | 145 | if Enum.count(primary_keyes) == 1 do 146 | [key] = primary_keyes 147 | key 148 | else 149 | [key, _] = primary_keyes 150 | key 151 | end 152 | end 153 | 154 | defp repo(opts) when is_list(opts), do: opts[:repo] || EctoCellar.repo() 155 | defp repo(_), do: EctoCellar.repo() 156 | 157 | defp model_id(model) do 158 | if id = Map.fetch!(model, primary_key(model)), do: to_string(id) 159 | end 160 | 161 | defp to_models(versions, mod) do 162 | versions 163 | |> Enum.map(&to_model(&1, mod)) 164 | end 165 | 166 | defp to_model(version, mod) do 167 | version = 168 | Jason.decode!(version.version) 169 | |> Enum.map(fn {key, value} -> 170 | { 171 | key |> String.to_existing_atom(), 172 | if(is_stored_native_datetime(value), do: restore_native_datetime(value), else: value) 173 | } 174 | end) 175 | 176 | struct( 177 | mod.__struct__, 178 | version 179 | ) 180 | end 181 | 182 | defp cast_format_map(%{__meta__: %{schema: schema}} = model) do 183 | for field <- schema.__schema__(:fields), 184 | into: %{} do 185 | {field, maybe_encode_native_datetime(Map.get(model, field))} 186 | end 187 | end 188 | 189 | defp maybe_encode_native_datetime(%NaiveDateTime{} = value), 190 | do: "#{@native_datetime_prefix}#{value}" 191 | 192 | defp maybe_encode_native_datetime(value), do: value 193 | 194 | defp is_stored_native_datetime(datetime_str), 195 | do: to_string(datetime_str) =~ @native_datetime_prefix 196 | 197 | defp restore_native_datetime(datetime_str) do 198 | datetime_str 199 | |> String.replace(@native_datetime_prefix, "") 200 | |> NaiveDateTime.from_iso8601!() 201 | end 202 | end 203 | -------------------------------------------------------------------------------- /test/ecto_cellar_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoCellarTest do 2 | use ExUnit.Case 3 | # use DataCase 4 | @repo Application.get_env(:ecto_cellar, :default_repo) 5 | setup do 6 | {:ok, post} = %Post{title: "title", views: 0} |> Post.changeset(%{}) |> @repo.insert() 7 | 8 | {:ok, article} = 9 | %Article{uuid: Ecto.UUID.generate(), title: "title", views: 0} |> @repo.insert() 10 | 11 | [post: post, article: article] 12 | end 13 | 14 | describe "store/2" do 15 | test "return {:ok, model}", %{post: post, article: article} do 16 | assert {:ok, %Post{title: "title", views: 0}} = EctoCellar.store(post) 17 | assert {:ok, %Article{title: "title", views: 0}} = EctoCellar.store(article, :uuid) 18 | assert {:ok, %Article{title: "title", views: 0}} = EctoCellar.store(article) 19 | end 20 | 21 | test "return {:error, term}", %{post: post, article: article} do 22 | assert {:error, %{errors: [model_id: {"can't be blank", [validation: :required]}]}} = 23 | EctoCellar.store(post |> Map.put(:id, nil)) 24 | 25 | assert {:error, %{errors: [model_id: {"can't be blank", [validation: :required]}]}} = 26 | EctoCellar.store(article |> Map.put(:uuid, nil), :uuid) 27 | end 28 | 29 | test "can pass othre repo", %{post: post} do 30 | assert_raise UndefinedFunctionError, 31 | "function :dummy.insert/1 is undefined (module :dummy is not available)", 32 | fn -> 33 | EctoCellar.store(post, repo: :dummy) 34 | end 35 | end 36 | end 37 | 38 | describe "store!/2" do 39 | test "return {:ok, model}", %{post: post, article: article} do 40 | assert %Post{title: "title", views: 0} = EctoCellar.store!(post) 41 | 42 | assert %Article{title: "title", views: 0} = EctoCellar.store!(article, :uuid) 43 | end 44 | 45 | test "raise Ecto.InvalidChangesetError", %{post: post, article: article} do 46 | assert_raise Ecto.InvalidChangesetError, 47 | fn -> 48 | EctoCellar.store!(post |> Map.put(:id, nil)) 49 | end 50 | 51 | assert_raise Ecto.InvalidChangesetError, 52 | fn -> 53 | EctoCellar.store!(article |> Map.put(:uuid, nil), :uuid) 54 | end 55 | end 56 | end 57 | 58 | describe "insert_store/3" do 59 | setup do 60 | [ 61 | post: %Post{title: "title", views: 0}, 62 | article: %Article{uuid: Ecto.UUID.generate(), title: "title", views: 0} 63 | ] 64 | end 65 | 66 | test "return {:ok, model}", %{post: post, article: article} do 67 | assert {:ok, %Post{title: "title", views: 0}} = EctoCellar.insert_store(post) 68 | 69 | assert {:ok, %Article{title: "title", views: 0}} = EctoCellar.insert_store(article) 70 | end 71 | end 72 | 73 | describe "update_store/3" do 74 | setup do 75 | {:ok, post} = %Post{title: "title", views: 0} |> @repo.insert() 76 | 77 | {:ok, article} = 78 | %Article{uuid: Ecto.UUID.generate(), title: "title", views: 0} |> @repo.insert() 79 | 80 | [ 81 | post: post |> Map.put(:views, 1) |> Post.changeset(%{}), 82 | article: article |> Map.put(:views, 1) |> Article.changeset(%{}) 83 | ] 84 | end 85 | 86 | test "return {:ok, model}", %{post: post, article: article} do 87 | assert {:ok, %Post{title: "title", views: 1}} = EctoCellar.update_store(post) 88 | 89 | assert {:ok, %Article{title: "title", views: 1}} = EctoCellar.update_store(article) 90 | end 91 | end 92 | 93 | describe "upsert_store/3" do 94 | setup do 95 | [ 96 | post: %Post{title: "title", views: 0} |> Post.changeset(%{}), 97 | article: 98 | %Article{uuid: Ecto.UUID.generate(), title: "title", views: 0} |> Article.changeset(%{}) 99 | ] 100 | end 101 | 102 | test "return {:ok, model}", %{post: post, article: article} do 103 | assert {:ok, %Post{title: "title", views: 0} = post} = EctoCellar.upsert_store(post) 104 | 105 | assert {:ok, %Post{title: "title", views: 1}} = 106 | post |> Post.changeset(%{views: 1}) |> EctoCellar.upsert_store() 107 | 108 | assert {:ok, %Article{title: "title", views: 0} = article} = 109 | EctoCellar.upsert_store(article) 110 | 111 | assert {:ok, %Article{title: "title", views: 1}} = 112 | article 113 | |> Article.changeset(%{views: 1}) 114 | |> EctoCellar.upsert_store() 115 | end 116 | end 117 | 118 | describe "delete_store/3" do 119 | setup do 120 | {:ok, post} = %Post{title: "title", views: 0} |> @repo.insert() 121 | 122 | {:ok, article} = 123 | %Article{uuid: Ecto.UUID.generate(), title: "title", views: 0} |> @repo.insert() 124 | 125 | [post: post, article: article] 126 | end 127 | 128 | test "return {:ok, model}", %{post: post, article: article} do 129 | assert {:ok, %Post{title: "title", views: 0}} = EctoCellar.delete_store(post) 130 | refute @repo.get(Post, post.id) 131 | 132 | assert {:ok, %Article{title: "title", views: 0}} = EctoCellar.delete_store(article) 133 | refute @repo.get(Article, article.uuid) 134 | end 135 | end 136 | 137 | describe "all/2" do 138 | setup ctx do 139 | 0..10 140 | |> Enum.each(fn _ -> 141 | {:ok, _} = EctoCellar.store(ctx[:post]) 142 | end) 143 | 144 | 0..10 145 | |> Enum.each(fn _ -> 146 | {:ok, _} = EctoCellar.store(ctx[:article], :uuid) 147 | end) 148 | 149 | 0..10 150 | |> Enum.each(fn _ -> 151 | {:ok, _} = EctoCellar.store(ctx[:article]) 152 | end) 153 | 154 | :ok 155 | end 156 | 157 | test "return models", %{post: post, article: article} do 158 | assert EctoCellar.all(post) |> Enum.count() >= 10 159 | assert EctoCellar.all(article, :uuid) |> Enum.count() >= 10 160 | assert EctoCellar.all(article) |> Enum.count() >= 10 161 | end 162 | end 163 | 164 | describe "one/2" do 165 | setup ctx do 166 | %Post{title: "title", views: 0} = EctoCellar.store!(ctx[:post]) 167 | %Article{title: "title", views: 0} = EctoCellar.store!(ctx[:article], :uuid) 168 | :ok 169 | end 170 | 171 | test "return model", %{post: post, article: article} do 172 | expected_post = %Post{ 173 | id: post.id, 174 | title: post.title, 175 | views: post.views, 176 | inserted_at: post.inserted_at, 177 | updated_at: post.updated_at 178 | } 179 | 180 | assert ^expected_post = restored = EctoCellar.one(post, post.inserted_at) 181 | assert {:ok, _} = restored |> Post.changeset(%{}) |> @repo.update() 182 | 183 | expected_article = %Article{ 184 | uuid: article.uuid, 185 | title: article.title, 186 | views: article.views, 187 | inserted_at: article.inserted_at, 188 | updated_at: article.updated_at 189 | } 190 | 191 | assert ^expected_article = EctoCellar.one(article, article.inserted_at, :uuid) 192 | 193 | assert ^expected_article = restored = EctoCellar.one(article, article.inserted_at) 194 | 195 | assert {:ok, _} = restored |> Article.changeset(%{}) |> @repo.update() 196 | end 197 | end 198 | 199 | describe "only versions non-virtual field names" do 200 | setup ctx do 201 | {:ok, comment} = 202 | %PostComment{content: "a comment", post_id: ctx.post.id, virtual: "not versioned"} 203 | |> @repo.insert() 204 | 205 | [comment: comment] 206 | end 207 | 208 | test "store does not cause Jason RuntimeError with associated fields", %{comment: comment} do 209 | assert {:ok, _} = EctoCellar.store(comment) 210 | end 211 | 212 | test "stored version does not include associated or virtual fields", %{comment: comment} do 213 | assert %PostComment{post: %Post{}} = preloaded = @repo.preload(comment, :post) 214 | 215 | assert %PostComment{} = EctoCellar.store!(preloaded) 216 | 217 | assert %PostComment{post: %Ecto.Association.NotLoaded{}, virtual: nil} = 218 | EctoCellar.one(preloaded, preloaded.inserted_at) 219 | end 220 | end 221 | end 222 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS --------------------------------------------------------------------------------