defmodule Versioned.Migration do
@moduledoc """
Allows creating tables for versioned schemas.
## Example
defmodule MyApp.Repo.Migrations.CreateCar do
use Versioned.Migration
def change do
create_versioned_table(:cars) do
add :name, :string
end
end
end
"""
alias Versioned.Helpers
defmacro __using__(_) do
quote do
use Ecto.Migration
import unquote(__MODULE__)
end
end
@doc """
Create a table whose data is versioned by also creating a secondary table
with the immutable, append-only history.
"""
defmacro create_versioned_table(name_plural, opts \\ [], do: block) do
name_singular = Keyword.get(opts, :singular, String.trim_trailing("#{name_plural}", "s"))
{:__block__, mid, lines} = Helpers.normalize_block(block)
to_block = fn lines -> {:__block__, mid, lines} end
# For versions table, rewrite references to avoid database constraints:
# If a record is deleted, we don't want version records with its foreign
# key to be affected.
versions_block =
lines
|> Enum.reduce([], &do_version_line/2)
|> Enum.reverse()
|> to_block.()
quote do
create table(unquote(name_plural), primary_key: false) do
add(:id, :uuid, primary_key: true)
timestamps(type: :utc_datetime_usec)
unquote(block)
end
create table(:"#{unquote(name_plural)}_versions", primary_key: false) do
add(:id, :uuid, primary_key: true)
add(:is_deleted, :boolean, null: false)
add(:"#{unquote(name_singular)}_id", :uuid, null: false)
timestamps(type: :utc_datetime_usec, updated_at: false)
unquote(versions_block)
end
create(index(:"#{unquote(name_plural)}_versions", :"#{unquote(name_singular)}_id"))
end
end
# Strip foreign key constraints for versions tables.
def versions_type(%Ecto.Migration.Reference{type: type}), do: fix_type(type)
def versions_type(other), do: fix_type(other)
defp fix_type(:bigserial), do: :bigint
defp fix_type(nil), do: :bigint
defp fix_type(other), do: other
# Take the original migration ast and attach to the accumulator the
# corresponding ast to use for the version table.
@spec do_version_line(Macro.t(), Macro.t()) :: Macro.t()
defp do_version_line({:add, a, [b, {:references, _, _} = tup]}, acc) do
do_version_line({:add, a, [b, tup, []]}, acc)
end
defp do_version_line({:add, m, [foreign_key, {:references, _m2, ref_args}, field_opts]}, acc) do
type = fix_type(Enum.at(ref_args, 1, [])[:type])
[{:add, m, [foreign_key, type, field_opts]} | acc]
end
defp do_version_line(line, acc) do
[line | acc]
end
@doc """
Add a new column to both the main table and the versions table.
Any foreign key constraint via `references/2` will be stripped for
the versions table.
"""
defmacro add_versioned_column(table_name, name, type, opts \\ []) do
quote do
alter table(unquote(table_name)) do
add(unquote(name), unquote(type), unquote(opts))
end
alter table("#{unquote(table_name)}_versions") do
add(unquote(name), versions_type(unquote(type)), unquote(opts))
end
end
end
@doc """
Rename `orig_field` column in table `table_name` to a new name.
See `Ecto.Migration.rename/3`.
Note that this is indeed changing the field names as well in the
complimenting and generally immutable "versions" table.
## Example
defmodule MyApp.Repo.Migrations.RenameFooToBar do
use Versioned.Migration
def change do
rename_versioned_column("my_table", :foo, to: :bar)
end
end
"""
defmacro rename_versioned_column(table_name, orig_field, opts) do
quote do
rename(table(unquote(table_name)), unquote(orig_field), unquote(opts))
rename(table(unquote("#{table_name}_versions")), unquote(orig_field), unquote(opts))
end
end
@doc """
Modify `orig_field` column in table `table_name` and its versioned
counterpart.
See `Ecto.Migration.modify/3`.
## Example
defmodule MyApp.Repo.Migrations.RenameFooToBar do
use Versioned.Migration
def change do
modify_versioned_column("my_table", :foo, :text, null: true)
end
end
"""
defmacro modify_versioned_column(table_name, column, type, opts \\ []) do
quote do
alter table(unquote(table_name)) do
modify(unquote(column), unquote(type), unquote(opts))
end
alter table(unquote("#{table_name}_versions")) do
modify(unquote(column), versions_type(unquote(type)), unquote(opts))
end
end
end
@doc """
Removes a column from a table and its versioned counterpart.
See `Ecto.Migration.remove/1`.
## Example
defmodule MyApp.Repo.Migrations.RemoveFooToBar do
use Versioned.Migration
def change do
remove_versioned_column("my_table", :foo)
end
end
"""
defmacro remove_versioned_column(table_name, column) do
quote do
alter table(unquote(table_name)) do
remove(unquote(column))
end
alter table(unquote("#{table_name}_versions")) do
remove(unquote(column))
end
end
end
@doc """
Renames a table table and its versioned counterpart.
See `Ecto.Migration.rename/2`.
## Other Considerations
If the table is `cars`, then `cars_versions` has a `car_id` field. If
the table is being renamed to `automobiles`, then after using this macro,
you'll want to also do something like the following in order to rename the
`car_id` field in the versions table to `automobile_id`.
rename table("automobiles_versions"), :car_id, to: :automobile_id
If your table has additional foreign key constraints such as those set up with
`Ecto.Migration.references/2`, you'll want to move those over, too, with
something like this. The versions table won't have any such constraints.
execute(
"ALTER TABLE automobiles RENAME CONSTRAINT cars_garage_id_fkey TO automobiles_garage_id_fkey;",
"ALTER TABLE automobiles RENAME CONSTRAINT automobiles_garage_id_fkey TO cars_garage_id_fkey;"
)
## Example
defmodule MyApp.Repo.Migrations.RenameCarsToAutomobiles do
use Versioned.Migration
def change do
rename_versioned_table("cars", "automobiles")
rename table("automobiles_versions"), :car_id, to: :automobile_id
end
end
"""
defmacro rename_versioned_table(orig_name, new_name) do
quote do
rename(table(unquote(orig_name)), to: table(unquote(new_name)))
rename(table("#{unquote(orig_name)}_versions"), to: table("#{unquote(new_name)}_versions"))
end
end
end