defmodule EctoCellar do @moduledoc """ Core module for EctoCellar. Handles versions table created by `mix ecto_cellar.gen`. You can use this module to store in the cellar and restore the version. ## Options - repo: You can select a repo other than the one specified in Config. """ alias EctoCellar.Version alias Ecto.Multi @native_datetime_prefix "ecto_cellar_native_datetime_" @type options :: Keyword.t() @doc """ Stores the changes at that time in the cellar. """ @spec store(Ecto.Schema.t() | Ecto.Changeset.t(), options) :: {:ok, Ecto.Schema.t()} | {:error, term()} def store(%mod{} = model, opts \\ []) do Version.create( %{ model_name: mod |> inspect(), model_id: model_id(model), model_inserted_at: model.inserted_at, version: model |> cast_format_map |> Jason.encode!() }, repo(opts) ) |> case do {:ok, _version} -> {:ok, model} error -> error end end @doc """ Like store/2, except that if the record is invalid, raises an exception. """ @spec store!(Ecto.Schema.t() | Ecto.Changeset.t(), options) :: Ecto.Schema.t() def store!(%mod{} = model, opts \\ []) do Version.create!( %{ model_name: mod |> inspect(), model_id: model_id(model), model_inserted_at: model.inserted_at, version: model |> cast_format_map |> Jason.encode!() }, repo(opts) ) model end @doc """ Inserts given model(or changeset) and stores the changes at that time in the cellar. - options: EctoCellar.options() - insert_opts: options for Ecto.Repo.insert/2 """ @spec insert_store(Ecto.Schema.t() | Ecto.Changeset.t(), options, Keyword.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} def insert_store(changeset, opts \\ [], insert_opts \\ []), do: do_wrap_func(changeset, opts, insert_opts, :insert) @doc """ Updates given changeset and stores the changes at that time in the cellar. - options: EctoCellar.options() - update_opts: options for Ecto.Repo.update/2 """ @spec update_store(Ecto.Changeset.t(), options, Keyword.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} def update_store(changeset, opts \\ [], update_opts \\ []), do: do_wrap_func(changeset, opts, update_opts, :update) @doc """ Inserts or updates given changeset and stores the changes at that time in the cellar. - options: EctoCellar.options() - insert_or_update_opts: options for Ecto.Repo.insert_or_update/2 """ @spec upsert_store(Ecto.Changeset.t(), options, Keyword.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} def upsert_store(changeset, opts \\ [], insert_or_update_opts \\ []), do: do_wrap_func(changeset, opts, insert_or_update_opts, :insert_or_update) @doc """ Deletes given changeset and stores the changes at that time in the cellar. - options: EctoCellar.options() - delete_opts: options for Ecto.Repo.update/2 """ @spec delete_store(Ecto.Schema.t() | Ecto.Changeset.t(), options, Keyword.t()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} def delete_store(changeset, opts \\ [], delete_opts \\ []), do: do_wrap_func(changeset, opts, delete_opts, :delete) defp do_wrap_func(changeset, celler_opts, ecto_opts, func_atom) do Multi.new() |> Multi.run(:schema, fn _repo, _ -> apply(repo(celler_opts), func_atom, [changeset, ecto_opts]) end) |> Multi.run(:store, fn _repo, %{schema: schema} -> store(schema, celler_opts) end) |> repo(celler_opts).transaction() |> case do {:ok, %{schema: schema}} -> {:ok, schema} error -> error end end @doc """ Returns a specific version of model from the cellar. """ @spec one(struct(), NaiveDateTime.t(), options) :: Ecto.Schema.t() def one(%mod{} = model, timestamp, opts \\ []) do Version.one( mod |> inspect(), timestamp, model_id(model), repo(opts) ) |> to_model(mod) end @doc """ Returns all versions of model from the cellar. """ @spec all(struct(), options) :: [Ecto.Schema.t()] def all(%mod{} = model, opts \\ []) do Version.all( mod |> inspect(), model_id(model), repo(opts) ) |> to_models(mod) end @doc false def repo, do: Application.get_env(:ecto_cellar, :default_repo) || Application.get_env(:ecto_cellar, :repo) defp primary_key(%{__meta__: %{schema: schema}}) do primary_keyes = schema.__schema__(:primary_key) if Enum.count(primary_keyes) == 1 do [key] = primary_keyes key else [key, _] = primary_keyes key end end defp repo(opts) when is_list(opts), do: opts[:repo] || EctoCellar.repo() defp repo(_), do: EctoCellar.repo() defp model_id(model) do if id = Map.fetch!(model, primary_key(model)), do: to_string(id) end defp to_models(versions, mod) do versions |> Enum.map(&to_model(&1, mod)) end defp to_model(version, mod) do version = Jason.decode!(version.version) |> Enum.map(fn {key, value} -> { key |> String.to_existing_atom(), if(is_stored_native_datetime(value), do: restore_native_datetime(value), else: value) } end) struct( mod.__struct__, version ) end defp cast_format_map(%{__meta__: %{schema: schema}} = model) do for field <- schema.__schema__(:fields), into: %{} do {field, maybe_encode_native_datetime(Map.get(model, field))} end end defp maybe_encode_native_datetime(%NaiveDateTime{} = value), do: "#{@native_datetime_prefix}#{value}" defp maybe_encode_native_datetime(value), do: value defp is_stored_native_datetime(datetime_str), do: to_string(datetime_str) =~ @native_datetime_prefix defp restore_native_datetime(datetime_str) do datetime_str |> String.replace(@native_datetime_prefix, "") |> NaiveDateTime.from_iso8601!() end end