Packages
ash_postgres
1.3.0-rc.2
2.10.0
2.9.1
2.9.0
2.8.0
2.7.0
2.6.32
2.6.31
2.6.30
2.6.29
2.6.28
2.6.27
2.6.26
2.6.25
2.6.24
2.6.23
2.6.22
2.6.21
2.6.20
2.6.19
2.6.18
2.6.17
2.6.16
2.6.15
2.6.14
2.6.13
2.6.12
2.6.11
2.6.10
2.6.9
2.6.8
2.6.7
2.6.6
2.6.5
2.6.4
2.6.3
2.6.2
2.6.1
2.6.0
2.5.22
2.5.21
2.5.20
2.5.19
2.5.18
2.5.17
2.5.16
2.5.15
2.5.14
2.5.13
2.5.12
2.5.11
2.5.10
2.5.9
2.5.8
2.5.7
2.5.6
2.5.5
2.5.4
2.5.3
2.5.2
2.5.1
2.5.0
2.4.21
2.4.20
2.4.19
2.4.18
2.4.17
2.4.16
2.4.15
2.4.14
2.4.13
2.4.12
2.4.11
2.4.10
2.4.9
2.4.8
2.4.7
2.4.6
2.4.5
2.4.4
2.4.3
2.4.2
2.4.1
2.4.0
2.3.1
2.3.0
2.2.5
2.2.4
2.2.3
2.2.2
2.2.1
2.2.0
2.1.19
2.1.18
2.1.17
2.1.15
2.1.14
2.1.13
2.1.12
2.1.11
2.1.10
2.1.9
2.1.8
2.1.6
2.1.5
2.1.4
2.1.3
2.1.2
2.1.1
2.1.0
2.0.12
2.0.11
2.0.10
2.0.9
2.0.8
2.0.7
2.0.6
2.0.5
2.0.4
2.0.3
2.0.2
2.0.1
2.0.0
2.0.0-rc.15
2.0.0-rc.14
2.0.0-rc.13
2.0.0-rc.12
2.0.0-rc.11
2.0.0-rc.10
2.0.0-rc.9
2.0.0-rc.8
2.0.0-rc.7
2.0.0-rc.6
2.0.0-rc.5
2.0.0-rc.4
2.0.0-rc.3
2.0.0-rc.2
2.0.0-rc.1
2.0.0-rc.0
1.5.30
1.5.29
1.5.28
1.5.27
1.5.26
1.5.25
1.5.24
1.5.23
1.5.22
1.5.21
1.5.20
1.5.19
1.5.18
1.5.17
1.5.16
1.5.15
1.5.14
1.5.13
1.5.12
1.5.11
1.5.10
1.5.9
1.5.8
1.5.7
1.5.6
1.5.5
1.5.4
1.5.3
1.5.2
1.5.1
1.5.0
1.4.0
1.3.68
1.3.67
1.3.66
1.3.65
1.3.64
1.3.63
1.3.62
1.3.61
1.3.60
1.3.59
1.3.58
1.3.56
1.3.55
1.3.54
1.3.53
1.3.52
1.3.51
1.3.50
1.3.49
1.3.48
1.3.47
1.3.46
1.3.45
1.3.44
1.3.43
1.3.42
1.3.41
1.3.40
1.3.39
1.3.38
1.3.37
1.3.36
1.3.35
1.3.34
1.3.33
1.3.32
1.3.31
1.3.30
1.3.29
1.3.28
1.3.26
1.3.25
1.3.24
1.3.23
1.3.22
1.3.21
1.3.20
1.3.19
1.3.18
1.3.17
1.3.16
1.3.15
1.3.14
1.3.12
1.3.11
1.3.10
1.3.9
1.3.8
1.3.6
1.3.5
1.3.4
1.3.3
1.3.2
1.3.1
1.3.0
1.3.0-rc.4
1.3.0-rc.3
1.3.0-rc.2
1.3.0-rc.0
1.2.6
1.2.5
1.2.4
1.2.3
1.2.2
1.2.1
1.2.0
1.2.0-rc.1
1.2.0-rc.0
1.1.3
1.1.2
1.1.1
1.1.0
1.0.0
1.0.0-rc.9
1.0.0-rc.8
1.0.0-rc.7
1.0.0-rc.6
1.0.0-rc.5
1.0.0-rc.4
1.0.0-rc.3
1.0.0-rc.2
1.0.0-rc.1
1.0.0-rc.0
1.0.0-pre.3
1.0.0-pre.2
1.0.0-pre.1
1.0.0-pre.0
0.43.0
0.42.0-rc.7
0.42.0-rc.6
0.42.0-rc.5
0.42.0-rc.4
0.42.0-rc.3
0.42.0-rc.1
0.42.0-rc.0
0.41.7
0.41.6
0.41.5
0.41.4
0.41.3
0.41.2
0.41.1
0.41.0-rc0
retired
0.41.0-rc.9
0.41.0-rc.8
0.41.0-rc.7
0.41.0-rc.6
0.41.0-rc.5
0.41.0-rc.4
0.41.0-rc.3
0.41.0-rc.2
0.41.0-rc.1
0.41.0-rc.0
0.40.11
0.40.10
0.40.9
0.40.8
0.40.7
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0-rc5
0.40.0-rc4
0.40.0-rc3
0.40.0-rc2
0.40.0-rc1
0.39.0-rc0
0.38.11
0.38.10
0.38.9
0.38.8
0.38.7
0.38.6
0.38.5
0.38.4
0.38.3
0.38.2
0.38.1
0.38.0
0.37.8
0.37.7
0.37.6
0.37.5
0.37.4
0.37.3
0.37.2
0.37.1
0.37.0
0.36.5
0.36.4
0.36.3
0.36.2
0.36.1
0.36.0
0.35.5
0.35.4
0.35.3
0.35.1
0.34.5
0.34.4
0.34.3
0.34.2
0.34.1
0.34.0
0.33.1
0.33.0
0.32.2
0.32.1
0.32.0
0.31.1
0.30.1
0.29.6
0.29.5
0.29.4
0.29.3
0.29.2
0.29.1
0.28.1
0.28.0
0.27.0
0.26.2
0.26.1
0.26.0
0.25.5
0.25.4
0.25.3
0.25.2
0.25.1
0.25.0
0.24.0
0.23.2
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.1
0.20.0
0.19.0
0.18.0
0.17.0
0.16.1
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.10.0
0.9.0
0.8.0
0.7.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.1
0.2.0
0.1.4
0.1.3
0.1.2
0.1.1
0.1.1-rc.2
0.1.1-rc.1
0.1.1-rc.0
0.1.0
The PostgreSQL data layer for Ash Framework
Current section
Files
Jump to
Current section
Files
lib/migration_generator/migration_generator.ex
defmodule AshPostgres.MigrationGenerator do
@moduledoc false
require Logger
import Mix.Generator
alias AshPostgres.MigrationGenerator.{Operation, Phase}
defstruct snapshot_path: nil,
migration_path: nil,
name: nil,
tenant_migration_path: nil,
quiet: false,
current_snapshots: nil,
answers: [],
no_shell?: false,
format: true,
dry_run: false,
check: false,
drop_columns: false
def generate(apis, opts \\ []) do
apis = List.wrap(apis)
opts = opts(opts)
all_resources = Enum.uniq(Enum.flat_map(apis, &Ash.Api.Info.resources/1))
{tenant_snapshots, snapshots} =
all_resources
|> Enum.filter(fn resource ->
Ash.DataLayer.data_layer(resource) == AshPostgres.DataLayer &&
AshPostgres.DataLayer.Info.migrate?(resource)
end)
|> Enum.flat_map(&get_snapshots(&1, all_resources))
|> Enum.split_with(&(&1.multitenancy.strategy == :context))
tenant_snapshots_to_include_in_global =
tenant_snapshots
|> Enum.filter(& &1.multitenancy.global)
|> Enum.map(&Map.put(&1, :multitenancy, %{strategy: nil, attribute: nil, global: nil}))
snapshots = snapshots ++ tenant_snapshots_to_include_in_global
repos =
snapshots
|> Enum.map(& &1.repo)
|> Enum.uniq()
Mix.shell().info("\nExtension Migrations: ")
create_extension_migrations(repos, opts)
Mix.shell().info("\nGenerating Tenant Migrations: ")
create_migrations(tenant_snapshots, opts, true)
Mix.shell().info("\nGenerating Migrations:")
create_migrations(snapshots, opts, false)
end
@doc """
A work in progress utility for getting snapshots.
Does not support everything supported by the migration generator.
"""
def take_snapshots(api, repo, only_resources \\ nil) do
all_resources = api |> Ash.Api.Info.resources() |> Enum.uniq()
all_resources
|> Enum.filter(fn resource ->
Ash.DataLayer.data_layer(resource) == AshPostgres.DataLayer &&
AshPostgres.DataLayer.Info.repo(resource) == repo &&
(is_nil(only_resources) || resource in only_resources)
end)
|> Enum.flat_map(&get_snapshots(&1, all_resources))
end
@doc """
A work in progress utility for getting operations between snapshots.
Does not support everything supported by the migration generator.
"""
def get_operations_from_snapshots(old_snapshots, new_snapshots, opts \\ []) do
opts = %{opts(opts) | no_shell?: true}
old_snapshots = Enum.map(old_snapshots, &sanitize_snapshot/1)
new_snapshots
|> deduplicate_snapshots(opts, old_snapshots)
|> fetch_operations(opts)
|> Enum.flat_map(&elem(&1, 1))
|> Enum.uniq()
|> organize_operations()
end
defp opts(opts) do
case struct(__MODULE__, opts) do
%{check: true} = opts ->
%{opts | dry_run: true}
opts ->
opts
end
end
defp snapshot_path(%{snapshot_path: snapshot_path}, _) when not is_nil(snapshot_path),
do: snapshot_path
defp snapshot_path(_config, repo) do
# Copied from ecto's mix task, thanks Ecto ❤️
config = repo.config()
app = Keyword.fetch!(config, :otp_app)
Path.join([Mix.Project.deps_paths()[app] || File.cwd!(), "priv", "resource_snapshots"])
end
@latest_ash_functions_version 0
@add_ash_functions """
execute(\"\"\"
CREATE OR REPLACE FUNCTION ash_elixir_or(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
AS $$ SELECT COALESCE(NULLIF($1, FALSE), $2) $$
LANGUAGE SQL;
\"\"\")
execute(\"\"\"
CREATE OR REPLACE FUNCTION ash_elixir_or(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE)
AS $$ SELECT COALESCE($1, $2) $$
LANGUAGE SQL;
\"\"\")
execute(\"\"\"
CREATE OR REPLACE FUNCTION ash_elixir_and(left BOOLEAN, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
SELECT CASE
WHEN $1 IS TRUE THEN $2
ELSE $1
END $$
LANGUAGE SQL;
\"\"\")
execute(\"\"\"
CREATE OR REPLACE FUNCTION ash_elixir_and(left ANYCOMPATIBLE, in right ANYCOMPATIBLE, out f1 ANYCOMPATIBLE) AS $$
SELECT CASE
WHEN $1 IS NOT NULL THEN $2
ELSE $1
END $$
LANGUAGE SQL;
\"\"\")
"""
@drop_ash_functions %{
0 => """
execute(\"DROP FUNCTION IF EXISTS ash_elixir_and(BOOLEAN, ANYCOMPATIBLE), ash_elixir_and(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(BOOLEAN, ANYCOMPATIBLE)\")
"""
}
defp create_extension_migrations(repos, opts) do
for repo <- repos do
snapshot_path = snapshot_path(opts, repo)
snapshot_file = Path.join(snapshot_path, "extensions.json")
installed_extensions =
if File.exists?(snapshot_file) do
snapshot_file
|> File.read!()
|> Jason.decode!(keys: :atoms!)
else
[]
end
{extensions_snapshot, installed_extensions} =
case installed_extensions do
installed when is_list(installed) ->
{%{
installed: installed
}
|> add_ash_functions(repo.installed_extensions, @latest_ash_functions_version - 1),
installed}
other ->
{other, other.installed}
end
to_install = List.wrap(repo.installed_extensions()) -- List.wrap(installed_extensions)
update_ash_functions? =
"ash-functions" in repo.installed_extensions() &&
extensions_snapshot.ash_functions_version != @latest_ash_functions_version
if Enum.empty?(to_install) && !update_ash_functions? do
Mix.shell().info("No extensions to install")
:ok
else
{module, migration_name} =
case to_install do
[single] ->
{"install_#{single}", "#{timestamp(true)}_install_#{single}_extension"}
multiple ->
{"install_#{Enum.count(multiple)}_extensions",
"#{timestamp(true)}_install_#{Enum.count(multiple)}_extensions"}
end
migration_file =
opts
|> migration_path(repo)
|> Path.join(migration_name <> ".exs")
sanitized_module =
module
|> String.replace("-", "_")
|> Macro.camelize()
module_name = Module.concat([repo, Migrations, sanitized_module])
install =
Enum.map_join(to_install, "\n", fn
"ash-functions" ->
@add_ash_functions
extension ->
"execute(\"CREATE EXTENSION IF NOT EXISTS \\\"#{extension}\\\"\")"
end)
install =
if update_ash_functions? do
"""
#{@drop_ash_functions[extensions_snapshot.ash_functions_version]}
#{@add_ash_functions}
#{install}
"""
else
install
end
uninstall =
Enum.map_join(to_install, "\n", fn
"ash-functions" ->
"execute(\"DROP FUNCTION IF EXISTS ash_elixir_and(BOOLEAN, ANYCOMPATIBLE), ash_elixir_and(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(ANYCOMPATIBLE, ANYCOMPATIBLE), ash_elixir_or(BOOLEAN, ANYCOMPATIBLE)\")"
extension ->
"# execute(\"DROP EXTENSION IF EXISTS \\\"#{extension}\\\"\")"
end)
contents = """
defmodule #{inspect(module_name)} do
@moduledoc \"\"\"
Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
This file was autogenerated with `mix ash_postgres.generate_migrations`
\"\"\"
use Ecto.Migration
def up do
#{install}
end
def down do
# Uncomment this if you actually want to uninstall the extensions
# when this migration is rolled back:
#{uninstall}
end
end
"""
snapshot_contents =
Jason.encode!(
%{
installed: repo.installed_extensions()
}
|> add_ash_functions(repo.installed_extensions(), @latest_ash_functions_version),
pretty: true
)
contents = format(contents, opts)
create_file(snapshot_file, snapshot_contents, force: true)
create_file(migration_file, contents)
end
end
end
defp add_ash_functions(snapshot, installed_extensions, version) do
if "ash-functions" in installed_extensions do
Map.put(snapshot, :ash_functions_version, version)
else
snapshot
end
end
defp create_migrations(snapshots, opts, tenant?) do
snapshots
|> Enum.group_by(& &1.repo)
|> Enum.each(fn {repo, snapshots} ->
deduped = deduplicate_snapshots(snapshots, opts)
snapshots_with_operations =
deduped
|> fetch_operations(opts)
|> Enum.map(&add_order_to_operations/1)
snapshots = Enum.map(snapshots_with_operations, &elem(&1, 0))
snapshots_with_operations
|> Enum.flat_map(&elem(&1, 1))
|> Enum.uniq()
|> case do
[] ->
tenant_str =
if tenant? do
"tenant "
else
""
end
Mix.shell().info(
"No #{tenant_str}changes detected, so no migrations or snapshots have been created."
)
:ok
operations ->
if opts.check do
IO.puts("""
Migrations would have been generated, but the --check flag was provided.
To see what migration would have been generated, run with the `--dry-run`
option instead. To generate those migrations, run without either flag.
""")
exit({:shutdown, 1})
end
operations
|> split_into_migrations()
|> Enum.each(fn operations ->
run_without_transaction? =
Enum.any?(operations, fn
%Operation.AddCustomIndex{index: %{concurrently: true}} ->
true
_ ->
false
end)
operations
|> organize_operations
|> build_up_and_down()
|> write_migration!(repo, opts, tenant?, run_without_transaction?)
end)
create_new_snapshot(snapshots, repo_name(repo), opts, tenant?)
end
end)
end
defp split_into_migrations(operations) do
operations
|> Enum.split_with(fn
%Operation.AddCustomIndex{index: %{concurrently: true}} ->
true
_ ->
false
end)
|> case do
{[], ops} ->
[ops]
{concurrent_indexes, ops} ->
[ops, concurrent_indexes]
end
end
defp add_order_to_operations({snapshot, operations}) do
operations_with_order = Enum.map(operations, &add_order_to_operation(&1, snapshot.attributes))
{snapshot, operations_with_order}
end
defp add_order_to_operation(%{attribute: attribute} = op, attributes) do
order = Enum.find_index(attributes, &(&1.source == attribute.source))
attribute = Map.put(attribute, :order, order)
%{op | attribute: attribute}
end
defp add_order_to_operation(%{new_attribute: attribute} = op, attributes) do
order = Enum.find_index(attributes, &(&1.source == attribute.source))
attribute = Map.put(attribute, :order, order)
%{op | new_attribute: attribute}
end
defp add_order_to_operation(op, _), do: op
defp organize_operations([]), do: []
defp organize_operations(operations) do
operations
|> sort_operations()
|> streamline()
|> group_into_phases()
|> comment_out_phases()
end
defp comment_out_phases(phases) do
Enum.map(phases, fn
%{operations: []} = phase ->
phase
%{operations: operations} = phase ->
if Enum.all?(operations, &match?(%{commented?: true}, &1)) do
%{phase | commented?: true}
else
phase
end
phase ->
phase
end)
end
defp deduplicate_snapshots(snapshots, opts, existing_snapshots \\ []) do
snapshots
|> Enum.group_by(fn snapshot ->
{snapshot.table, snapshot.schema}
end)
|> Enum.map(fn {_table, [snapshot | _] = snapshots} ->
existing_snapshot =
if opts.no_shell? do
Enum.find(existing_snapshots, &(&1.table == snapshot.table))
else
get_existing_snapshot(snapshot, opts)
end
{primary_key, identities} = merge_primary_keys(existing_snapshot, snapshots, opts)
attributes = Enum.flat_map(snapshots, & &1.attributes)
count_with_create =
snapshots
|> Enum.filter(& &1.has_create_action)
|> Enum.count()
new_snapshot = %{
snapshot
| attributes: merge_attributes(attributes, snapshot.table, count_with_create),
custom_indexes: snapshots |> Enum.flat_map(& &1.custom_indexes) |> Enum.uniq(),
custom_statements: snapshots |> Enum.flat_map(& &1.custom_statements) |> Enum.uniq()
}
all_identities =
new_snapshot.identities
|> Kernel.++(identities)
|> Enum.sort_by(& &1.name)
# We sort the identities by there being an identity with a matching name in the existing snapshot
# so that we prefer identities that currently exist over new ones
|> Enum.sort_by(fn identity ->
existing_snapshot
|> Kernel.||(%{})
|> Map.get(:identities, [])
|> Enum.any?(fn existing_identity ->
existing_identity.name == identity.name
end)
|> Kernel.!()
end)
|> Enum.uniq_by(fn identity ->
{Enum.sort(identity.keys), identity.base_filter}
end)
new_snapshot = %{new_snapshot | identities: all_identities}
{
%{
new_snapshot
| attributes:
Enum.map(new_snapshot.attributes, fn attribute ->
if attribute.source in primary_key do
%{attribute | primary_key?: true}
else
%{attribute | primary_key?: false}
end
end)
},
existing_snapshot
}
end)
end
defp merge_attributes(attributes, table, count) do
attributes
|> Enum.with_index()
|> Enum.map(fn {attr, i} -> Map.put(attr, :order, i) end)
|> Enum.group_by(& &1.source)
|> Enum.map(fn {source, attributes} ->
size =
attributes
|> Enum.map(& &1.size)
|> Enum.filter(& &1)
|> case do
[] ->
nil
sizes ->
Enum.max(sizes)
end
%{
source: source,
type: merge_types(Enum.map(attributes, & &1.type), source, table),
size: size,
default: merge_defaults(Enum.map(attributes, & &1.default)),
allow_nil?: Enum.any?(attributes, & &1.allow_nil?) || Enum.count(attributes) < count,
generated?: Enum.any?(attributes, & &1.generated?),
references: merge_references(Enum.map(attributes, & &1.references), source, table),
primary_key?: false,
order: attributes |> Enum.map(& &1.order) |> Enum.min()
}
end)
|> Enum.sort(&(&1.order < &2.order))
|> Enum.map(&Map.drop(&1, [:order]))
end
defp merge_references(references, name, table) do
references
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> case do
[] ->
nil
references ->
%{
destination_attribute: merge_uniq!(references, table, :destination_attribute, name),
destination_attribute_default:
merge_uniq!(references, table, :destination_attribute_default, name),
destination_attribute_generated:
merge_uniq!(references, table, :destination_attribute_generated, name),
multitenancy: merge_uniq!(references, table, :multitenancy, name),
on_delete: merge_uniq!(references, table, :on_delete, name),
on_update: merge_uniq!(references, table, :on_update, name),
name: merge_uniq!(references, table, :name, name),
table: merge_uniq!(references, table, :table, name),
schema: merge_uniq!(references, table, :schema, name)
}
end
end
defp merge_uniq!(references, table, field, attribute) do
references
|> Enum.map(&Map.get(&1, field))
|> Enum.filter(& &1)
|> Enum.uniq()
|> case do
[] ->
nil
[value] ->
value
values ->
values = Enum.map_join(values, "\n", &" * #{inspect(&1)}")
raise """
Conflicting configurations for references for #{table}.#{attribute}:
Values:
#{values}
"""
end
end
defp merge_types(types, name, table) do
types
|> Enum.uniq()
|> case do
[type] ->
type
types ->
raise "Conflicting types for table `#{table}.#{name}`: #{inspect(types)}"
end
end
defp merge_defaults(defaults) do
defaults
|> Enum.uniq()
|> case do
[default] -> default
_ -> "nil"
end
end
defp merge_primary_keys(nil, [snapshot | _] = snapshots, opts) do
snapshots
|> Enum.map(&pkey_names(&1.attributes))
|> Enum.uniq()
|> case do
[pkey_names] ->
{pkey_names, []}
unique_primary_keys ->
unique_primary_key_names =
unique_primary_keys
|> Enum.with_index()
|> Enum.map_join("\n", fn {pkey, index} ->
"#{index}: #{inspect(pkey)}"
end)
choice =
if opts.no_shell? do
raise "Unimplemented: cannot resolve primary key ambiguity without shell input"
else
message = """
Which primary key should be used for the table `#{snapshot.table}` (enter the number)?
#{unique_primary_key_names}
"""
message
|> Mix.shell().prompt()
|> String.to_integer()
end
identities =
unique_primary_keys
|> List.delete_at(choice)
|> Enum.map(fn pkey_names ->
pkey_name_string = Enum.join(pkey_names, "_")
name = snapshot.table <> "_" <> pkey_name_string
%{
keys: pkey_names,
name: name
}
end)
primary_key = Enum.sort(Enum.at(unique_primary_keys, choice))
identities =
Enum.reject(identities, fn identity ->
Enum.sort(identity.keys) == primary_key
end)
{primary_key, identities}
end
end
defp merge_primary_keys(existing_snapshot, snapshots, opts) do
pkey_names = pkey_names(existing_snapshot.attributes)
one_pkey_exists? =
Enum.any?(snapshots, fn snapshot ->
pkey_names(snapshot.attributes) == pkey_names
end)
if one_pkey_exists? do
identities =
snapshots
|> Enum.map(&pkey_names(&1.attributes))
|> Enum.uniq()
|> Enum.reject(&(&1 == pkey_names))
|> Enum.map(fn pkey_names ->
pkey_name_string = Enum.join(pkey_names, "_")
name = existing_snapshot.table <> "_" <> pkey_name_string
%{
keys: pkey_names,
name: name
}
end)
{pkey_names, identities}
else
merge_primary_keys(nil, snapshots, opts)
end
end
defp pkey_names(attributes) do
attributes
|> Enum.filter(& &1.primary_key?)
|> Enum.map(& &1.source)
|> Enum.sort()
end
defp migration_path(opts, repo, tenant? \\ false) do
repo_name = repo_name(repo)
# Copied from ecto's mix task, thanks Ecto ❤️
config = repo.config()
app = Keyword.fetch!(config, :otp_app)
if tenant? do
if opts.tenant_migration_path do
opts.tenant_migration_path
else
Path.join([Mix.Project.deps_paths()[app] || File.cwd!(), "priv"])
end
|> Path.join(repo_name)
|> Path.join("tenant_migrations")
else
if opts.migration_path do
opts.migration_path
else
Path.join([Mix.Project.deps_paths()[app] || File.cwd!(), "priv"])
end
|> Path.join(repo_name)
|> Path.join("migrations")
end
end
defp repo_name(repo) do
repo |> Module.split() |> List.last() |> Macro.underscore()
end
defp write_migration!({up, down}, repo, opts, tenant?, run_without_transaction?) do
migration_path = migration_path(opts, repo, tenant?)
{migration_name, last_part} =
if opts.name do
{"#{timestamp(true)}_#{opts.name}", "#{opts.name}"}
else
count =
migration_path
|> Path.join("*_migrate_resources*")
|> Path.wildcard()
|> Enum.count()
|> Kernel.+(1)
{"#{timestamp(true)}_migrate_resources#{count}", "migrate_resources#{count}"}
end
migration_file =
migration_path
|> Path.join(migration_name <> ".exs")
module_name =
if tenant? do
Module.concat([repo, TenantMigrations, Macro.camelize(last_part)])
else
Module.concat([repo, Migrations, Macro.camelize(last_part)])
end
module_attributes =
if run_without_transaction? do
"""
@disable_ddl_transaction true
@disable_migration_lock true
"""
end
contents = """
defmodule #{inspect(module_name)} do
@moduledoc \"\"\"
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
\"\"\"
use Ecto.Migration
#{module_attributes}
def up do
#{up}
end
def down do
#{down}
end
end
"""
try do
contents = format(contents, opts)
if opts.dry_run do
Mix.shell().info(contents)
else
create_file(migration_file, contents)
end
rescue
exception ->
reraise(
"""
Exception while formatting generated code:
#{Exception.format(:error, exception, __STACKTRACE__)}
Code:
#{add_line_numbers(contents)}
To generate it unformatted anyway, but manually fix it, use the `--no-format` option.
""",
__STACKTRACE__
)
end
end
defp add_line_numbers(contents) do
lines = String.split(contents, "\n")
digits = String.length(to_string(Enum.count(lines)))
lines
|> Enum.with_index()
|> Enum.map_join("\n", fn {line, index} ->
"#{String.pad_trailing(to_string(index), digits, " ")} | #{line}"
end)
end
defp create_new_snapshot(snapshots, repo_name, opts, tenant?) do
unless opts.dry_run do
Enum.each(snapshots, fn snapshot ->
snapshot_binary = snapshot_to_binary(snapshot)
snapshot_folder =
if tenant? do
opts
|> snapshot_path(snapshot.repo)
|> Path.join(repo_name)
|> Path.join("tenants")
else
opts
|> snapshot_path(snapshot.repo)
|> Path.join(repo_name)
end
snapshot_file = Path.join(snapshot_folder, "#{snapshot.table}/#{timestamp()}.json")
File.mkdir_p(Path.dirname(snapshot_file))
File.write!(snapshot_file, snapshot_binary, [])
old_snapshot_folder = Path.join(snapshot_folder, "#{snapshot.table}.json")
if File.exists?(old_snapshot_folder) do
new_snapshot_folder = Path.join(snapshot_folder, "#{snapshot.table}/initial.json")
File.rename(old_snapshot_folder, new_snapshot_folder)
end
end)
end
end
@doc false
def build_up_and_down(phases) do
up =
Enum.map_join(phases, "\n", fn phase ->
phase
|> phase.__struct__.up()
|> Kernel.<>("\n")
|> maybe_comment(phase)
end)
down =
phases
|> Enum.reverse()
|> Enum.map_join("\n", fn phase ->
phase
|> phase.__struct__.down()
|> Kernel.<>("\n")
|> maybe_comment(phase)
end)
{up, down}
end
defp maybe_comment(text, %{commented?: true}) do
text
|> String.split("\n")
|> Enum.map_join("\n", fn line ->
if String.starts_with?(line, "#") do
line
else
"# #{line}"
end
end)
end
defp maybe_comment(text, _), do: text
defp format(string, opts) do
if opts.format do
Code.format_string!(string, locals_without_parens: ecto_sql_locals_without_parens())
else
string
end
rescue
exception ->
IO.puts("""
Exception while formatting:
#{inspect(exception)}
#{inspect(string)}
""")
reraise exception, __STACKTRACE__
end
defp ecto_sql_locals_without_parens do
path = File.cwd!() |> Path.join("deps/ecto_sql/.formatter.exs")
if File.exists?(path) do
{opts, _} = Code.eval_file(path)
Keyword.get(opts, :locals_without_parens, [])
else
[]
end
end
defp streamline(ops, acc \\ [])
defp streamline([], acc), do: Enum.reverse(acc)
defp streamline(
[
%Operation.AddAttribute{
attribute: %{
source: name
},
schema: schema,
table: table
} = add
| rest
],
acc
) do
rest
|> Enum.take_while(fn
%custom{} when custom in [Operation.AddCustomStatement, Operation.RemoveCustomStatement] ->
false
op ->
op.table == table && op.schema == schema
end)
|> Enum.with_index()
|> Enum.find(fn
{%Operation.AlterAttribute{
new_attribute: %{source: ^name, references: references},
old_attribute: %{source: ^name}
}, _}
when not is_nil(references) ->
true
_ ->
false
end)
|> case do
nil ->
streamline(rest, [add | acc])
{alter, index} ->
new_attribute = Map.put(add.attribute, :references, alter.new_attribute.references)
streamline(List.delete_at(rest, index), [%{add | attribute: new_attribute} | acc])
end
end
defp streamline([first | rest], acc) do
streamline(rest, [first | acc])
end
defp group_into_phases(ops, current \\ nil, acc \\ [])
defp group_into_phases([], nil, acc), do: Enum.reverse(acc)
defp group_into_phases([], phase, acc) do
phase = %{phase | operations: Enum.reverse(phase.operations)}
Enum.reverse([phase | acc])
end
defp group_into_phases(
[
%Operation.CreateTable{table: table, schema: schema, multitenancy: multitenancy} | rest
],
nil,
acc
) do
group_into_phases(
rest,
%Phase.Create{table: table, schema: schema, multitenancy: multitenancy},
acc
)
end
defp group_into_phases(
[%Operation.AddAttribute{table: table, schema: schema} = op | rest],
%{table: table, schema: schema} = phase,
acc
) do
group_into_phases(rest, %{phase | operations: [op | phase.operations]}, acc)
end
defp group_into_phases(
[%Operation.AlterAttribute{table: table, schema: schema} = op | rest],
%Phase.Alter{table: table, schema: schema} = phase,
acc
) do
group_into_phases(rest, %{phase | operations: [op | phase.operations]}, acc)
end
defp group_into_phases(
[%Operation.RenameAttribute{table: table, schema: schema} = op | rest],
%Phase.Alter{table: table, schema: schema} = phase,
acc
) do
group_into_phases(rest, %{phase | operations: [op | phase.operations]}, acc)
end
defp group_into_phases(
[%Operation.RemoveAttribute{table: table, schema: schema} = op | rest],
%{table: table, schema: schema} = phase,
acc
) do
group_into_phases(rest, %{phase | operations: [op | phase.operations]}, acc)
end
defp group_into_phases([%{no_phase: true} = op | rest], nil, acc) do
group_into_phases(rest, nil, [op | acc])
end
defp group_into_phases([operation | rest], nil, acc) do
phase = %Phase.Alter{
operations: [operation],
multitenancy: operation.multitenancy,
table: operation.table,
schema: operation.schema
}
group_into_phases(rest, phase, acc)
end
defp group_into_phases(operations, phase, acc) do
phase = %{phase | operations: Enum.reverse(phase.operations)}
group_into_phases(operations, nil, [phase | acc])
end
defp sort_operations(ops, acc \\ [])
defp sort_operations([], acc), do: acc
defp sort_operations([op | rest], []), do: sort_operations(rest, [op])
defp sort_operations([op | rest], acc) do
acc = Enum.reverse(acc)
after_index = Enum.find_index(acc, &after?(op, &1))
new_acc =
if after_index do
acc
|> List.insert_at(after_index, op)
|> Enum.reverse()
else
[op | Enum.reverse(acc)]
end
sort_operations(rest, new_acc)
end
defp after?(
%Operation.AddCustomStatement{},
_
),
do: true
defp after?(
_,
%Operation.RemoveCustomStatement{}
),
do: true
defp after?(
%Operation.AddAttribute{attribute: %{order: l}, table: table, schema: schema},
%Operation.AddAttribute{attribute: %{order: r}, table: table, schema: schema}
),
do: l > r
defp after?(
%Operation.RenameUniqueIndex{
table: table,
schema: schema
},
%{table: table, schema: schema}
) do
true
end
defp after?(
%Operation.AddUniqueIndex{
table: table,
schema: schema
},
%{table: table, schema: schema}
) do
true
end
defp after?(
%Operation.AddCheckConstraint{
constraint: %{attribute: attribute_or_attributes},
table: table,
multitenancy: multitenancy,
schema: schema
},
%Operation.AddAttribute{table: table, attribute: %{source: source}, schema: schema}
) do
source in List.wrap(attribute_or_attributes) ||
(multitenancy.attribute && multitenancy.attribute in List.wrap(attribute_or_attributes))
end
defp after?(
%Operation.AddCustomIndex{
table: table,
schema: schema
},
%Operation.AddAttribute{table: table, schema: schema}
) do
true
end
defp after?(
%Operation.AddCustomIndex{
table: table,
schema: schema,
index: %{
concurrently: true
}
},
%Operation.AddCustomIndex{
table: table,
schema: schema,
index: %{
concurrently: false
}
}
) do
true
end
defp after?(
%Operation.AddCheckConstraint{table: table, schema: schema, constraint: %{name: name}},
%Operation.RemoveCheckConstraint{
table: table,
schema: schema,
constraint: %{
name: name
}
}
),
do: true
defp after?(
%Operation.AddCheckConstraint{
constraint: %{attribute: attribute_or_attributes},
table: table,
schema: schema
},
%Operation.AlterAttribute{table: table, new_attribute: %{source: source}, schema: schema}
) do
source in List.wrap(attribute_or_attributes)
end
defp after?(
%Operation.AddCheckConstraint{
constraint: %{attribute: attribute_or_attributes},
table: table,
schema: schema
},
%Operation.RenameAttribute{
table: table,
new_attribute: %{source: source},
schema: schema
}
) do
source in List.wrap(attribute_or_attributes)
end
defp after?(
%Operation.RemoveUniqueIndex{table: table, schema: schema},
%Operation.AddUniqueIndex{table: table, schema: schema}
) do
false
end
defp after?(
%Operation.RemoveUniqueIndex{table: table, schema: schema},
%{table: table, schema: schema}
) do
true
end
defp after?(
%Operation.RemoveCheckConstraint{
constraint: %{attribute: attributes},
table: table,
schema: schema
},
%Operation.RemoveAttribute{table: table, attribute: %{source: source}, schema: schema}
) do
source in List.wrap(attributes)
end
defp after?(
%Operation.RemoveCheckConstraint{
constraint: %{attribute: attributes},
table: table,
schema: schema
},
%Operation.RenameAttribute{
table: table,
old_attribute: %{source: source},
schema: schema
}
) do
source in List.wrap(attributes)
end
defp after?(%Operation.AlterAttribute{table: table, schema: schema}, %Operation.DropForeignKey{
table: table,
schema: schema,
direction: :up
}),
do: true
defp after?(
%Operation.DropForeignKey{
table: table,
schema: schema,
direction: :down
},
%Operation.AlterAttribute{table: table, schema: schema}
),
do: true
defp after?(%Operation.AddAttribute{table: table, schema: schema}, %Operation.CreateTable{
table: table,
schema: schema
}) do
true
end
defp after?(
%Operation.AddAttribute{
attribute: %{
references: %{table: table, destination_attribute: name}
}
},
%Operation.AddAttribute{table: table, attribute: %{source: name}}
),
do: true
defp after?(
%Operation.AddAttribute{
table: table,
schema: schema,
attribute: %{
primary_key?: false
}
},
%Operation.AddAttribute{schema: schema, table: table, attribute: %{primary_key?: true}}
),
do: true
defp after?(
%Operation.AddAttribute{
table: table,
schema: schema,
attribute: %{
primary_key?: true
}
},
%Operation.RemoveAttribute{
schema: schema,
table: table,
attribute: %{primary_key?: true}
}
),
do: true
defp after?(
%Operation.AlterAttribute{
table: table,
schema: schema,
new_attribute: %{primary_key?: false},
old_attribute: %{primary_key?: true}
},
%Operation.AddAttribute{
table: table,
schema: schema,
attribute: %{
primary_key?: true
}
}
),
do: true
defp after?(
%Operation.RemoveAttribute{attribute: %{source: source}, table: table},
%Operation.AlterAttribute{
old_attribute: %{
references: %{table: table, destination_attribute: source}
}
}
),
do: true
defp after?(
%Operation.AlterAttribute{
new_attribute: %{
references: %{table: table, destination_attribute: name}
}
},
%Operation.AddAttribute{table: table, attribute: %{source: name}}
),
do: true
defp after?(%Operation.AddCheckConstraint{table: table, schema: schema}, %Operation.CreateTable{
table: table,
schema: schema
}) do
true
end
defp after?(
%Operation.AlterAttribute{new_attribute: %{references: references}, table: table},
%{table: table}
)
when not is_nil(references),
do: true
defp after?(%Operation.AddCheckConstraint{}, _), do: true
defp after?(%Operation.RemoveCheckConstraint{}, _), do: true
defp after?(_, _), do: false
defp fetch_operations(snapshots, opts) do
snapshots
|> Enum.map(fn {snapshot, existing_snapshot} ->
{snapshot, do_fetch_operations(snapshot, existing_snapshot, opts)}
end)
|> Enum.reject(fn {_, ops} ->
Enum.empty?(ops)
end)
end
defp do_fetch_operations(snapshot, existing_snapshot, opts, acc \\ [])
defp do_fetch_operations(
%{schema: new_schema} = snapshot,
%{schema: old_schema},
opts,
[]
)
when new_schema != old_schema do
do_fetch_operations(snapshot, nil, opts, [])
end
defp do_fetch_operations(snapshot, nil, opts, acc) do
empty_snapshot = %{
attributes: [],
identities: [],
schema: nil,
custom_indexes: [],
custom_statements: [],
check_constraints: [],
table: snapshot.table,
repo: snapshot.repo,
base_filter: nil,
multitenancy: %{
attribute: nil,
strategy: nil,
global: nil
}
}
do_fetch_operations(snapshot, empty_snapshot, opts, [
%Operation.CreateTable{
table: snapshot.table,
schema: snapshot.schema,
multitenancy: snapshot.multitenancy,
old_multitenancy: empty_snapshot.multitenancy
}
| acc
])
end
defp do_fetch_operations(snapshot, old_snapshot, opts, acc) do
attribute_operations = attribute_operations(snapshot, old_snapshot, opts)
rewrite_all_identities? = changing_multitenancy_affects_identities?(snapshot, old_snapshot)
custom_statements_to_add =
snapshot.custom_statements
|> Enum.reject(fn statement ->
Enum.any?(old_snapshot.custom_statements, &(&1.name == statement.name))
end)
|> Enum.map(&%Operation.AddCustomStatement{statement: &1, table: snapshot.table})
custom_statements_to_remove =
old_snapshot.custom_statements
|> Enum.reject(fn old_statement ->
Enum.any?(snapshot.custom_statements, &(&1.name == old_statement.name))
end)
|> Enum.map(&%Operation.RemoveCustomStatement{statement: &1, table: snapshot.table})
custom_statements_to_alter =
snapshot.custom_statements
|> Enum.flat_map(fn statement ->
old_statement = Enum.find(old_snapshot.custom_statements, &(&1.name == statement.name))
if old_statement &&
(old_statement.code? != statement.code? ||
old_statement.up != statement.up || old_statement.down != statement.down) do
[
%Operation.RemoveCustomStatement{statement: old_statement, table: snapshot.table},
%Operation.AddCustomStatement{statement: statement, table: snapshot.table}
]
else
[]
end
end)
custom_indexes_to_add =
Enum.filter(snapshot.custom_indexes, fn index ->
!Enum.find(old_snapshot.custom_indexes, fn old_custom_index ->
indexes_match?(old_custom_index, index)
end)
end)
|> Enum.map(fn custom_index ->
%Operation.AddCustomIndex{
index: custom_index,
table: snapshot.table,
schema: snapshot.schema,
multitenancy: snapshot.multitenancy,
base_filter: snapshot.base_filter
}
end)
custom_indexes_to_remove =
Enum.filter(old_snapshot.custom_indexes, fn old_custom_index ->
rewrite_all_identities? ||
!Enum.find(snapshot.custom_indexes, fn index ->
indexes_match?(old_custom_index, index)
end)
end)
|> Enum.map(fn custom_index ->
%Operation.RemoveCustomIndex{
index: custom_index,
table: old_snapshot.table,
schema: old_snapshot.schema,
multitenancy: old_snapshot.multitenancy,
base_filter: old_snapshot.base_filter
}
end)
unique_indexes_to_remove =
if rewrite_all_identities? do
old_snapshot.identities
else
Enum.reject(old_snapshot.identities, fn old_identity ->
Enum.find(snapshot.identities, fn identity ->
identity.name == old_identity.name &&
Enum.sort(old_identity.keys) == Enum.sort(identity.keys) &&
old_identity.base_filter == identity.base_filter
end)
end)
end
|> Enum.map(fn identity ->
%Operation.RemoveUniqueIndex{
identity: identity,
table: snapshot.table,
schema: snapshot.schema
}
end)
unique_indexes_to_rename =
if rewrite_all_identities? do
[]
else
snapshot.identities
|> Enum.map(fn identity ->
Enum.find_value(old_snapshot.identities, fn old_identity ->
if old_identity.name == identity.name &&
old_identity.index_name != identity.index_name do
{old_identity, identity}
end
end)
end)
|> Enum.filter(& &1)
end
|> Enum.map(fn {old_identity, new_identity} ->
%Operation.RenameUniqueIndex{
old_identity: old_identity,
new_identity: new_identity,
schema: snapshot.schema,
table: snapshot.table
}
end)
unique_indexes_to_add =
if rewrite_all_identities? do
snapshot.identities
else
Enum.reject(snapshot.identities, fn identity ->
Enum.find(old_snapshot.identities, fn old_identity ->
old_identity.name == identity.name &&
Enum.sort(old_identity.keys) == Enum.sort(identity.keys) &&
old_identity.base_filter == identity.base_filter
end)
end)
end
|> Enum.map(fn identity ->
%Operation.AddUniqueIndex{
identity: identity,
schema: snapshot.schema,
table: snapshot.table
}
end)
constraints_to_add =
snapshot.check_constraints
|> Enum.reject(fn constraint ->
Enum.find(old_snapshot.check_constraints, fn old_constraint ->
old_constraint.check == constraint.check && old_constraint.name == constraint.name
end)
end)
|> Enum.map(fn constraint ->
%Operation.AddCheckConstraint{
constraint: constraint,
table: snapshot.table,
schema: snapshot.schema
}
end)
constraints_to_remove =
old_snapshot.check_constraints
|> Enum.reject(fn old_constraint ->
Enum.find(snapshot.check_constraints, fn constraint ->
old_constraint.check == constraint.check && old_constraint.name == constraint.name
end)
end)
|> Enum.map(fn old_constraint ->
%Operation.RemoveCheckConstraint{
constraint: old_constraint,
table: old_snapshot.table,
schema: old_snapshot.schema
}
end)
[
unique_indexes_to_remove,
attribute_operations,
unique_indexes_to_add,
unique_indexes_to_rename,
constraints_to_remove,
constraints_to_add,
custom_indexes_to_add,
custom_indexes_to_remove,
custom_statements_to_add,
custom_statements_to_remove,
custom_statements_to_alter,
acc
]
|> Enum.concat()
|> Enum.map(&Map.put(&1, :multitenancy, snapshot.multitenancy))
|> Enum.map(&Map.put(&1, :old_multitenancy, old_snapshot.multitenancy))
end
defp indexes_match?(left, right) do
left =
Map.update!(left, :fields, fn fields ->
Enum.map(fields, &to_string/1)
end)
right =
Map.update!(right, :fields, fn fields ->
Enum.map(fields, &to_string/1)
end)
left == right
end
defp attribute_operations(snapshot, old_snapshot, opts) do
attributes_to_add =
Enum.reject(snapshot.attributes, fn attribute ->
Enum.find(old_snapshot.attributes, &(&1.source == attribute.source))
end)
attributes_to_remove =
Enum.reject(old_snapshot.attributes, fn attribute ->
Enum.find(snapshot.attributes, &(&1.source == attribute.source))
end)
{attributes_to_add, attributes_to_remove, attributes_to_rename} =
resolve_renames(snapshot.table, attributes_to_add, attributes_to_remove, opts)
attributes_to_alter =
snapshot.attributes
|> Enum.map(fn attribute ->
{attribute,
Enum.find(
old_snapshot.attributes,
&(&1.source == attribute.source && attributes_unequal?(&1, attribute, snapshot.repo))
)}
end)
|> Enum.filter(&elem(&1, 1))
rename_attribute_events =
Enum.map(attributes_to_rename, fn {new, old} ->
%Operation.RenameAttribute{
new_attribute: new,
old_attribute: old,
table: snapshot.table,
schema: snapshot.schema
}
end)
add_attribute_events =
Enum.flat_map(attributes_to_add, fn attribute ->
if attribute.references do
[
%Operation.AddAttribute{
attribute: Map.delete(attribute, :references),
schema: snapshot.schema,
table: snapshot.table
},
%Operation.AlterAttribute{
old_attribute: Map.delete(attribute, :references),
new_attribute: attribute,
schema: snapshot.schema,
table: snapshot.table
},
%Operation.DropForeignKey{
attribute: attribute,
table: snapshot.table,
schema: snapshot.schema,
multitenancy: Map.get(attribute, :multitenancy),
direction: :down
}
]
else
[
%Operation.AddAttribute{
attribute: attribute,
table: snapshot.table,
schema: snapshot.schema
}
]
end
end)
alter_attribute_events =
Enum.flat_map(attributes_to_alter, fn {new_attribute, old_attribute} ->
if has_reference?(old_snapshot.multitenancy, old_attribute) and
Map.get(old_attribute, :references) != Map.get(new_attribute, :references) do
old_and_alter = [
%Operation.DropForeignKey{
attribute: old_attribute,
table: snapshot.table,
schema: snapshot.schema,
multitenancy: old_snapshot.multitenancy,
direction: :up
},
%Operation.AlterAttribute{
new_attribute: new_attribute,
old_attribute: old_attribute,
schema: snapshot.schema,
table: snapshot.table
}
]
if has_reference?(snapshot.multitenancy, new_attribute) do
old_and_alter ++
[
%Operation.DropForeignKey{
attribute: new_attribute,
table: snapshot.table,
schema: snapshot.schema,
multitenancy: snapshot.multitenancy,
direction: :down
}
]
else
old_and_alter
end
else
[
%Operation.AlterAttribute{
new_attribute: Map.delete(new_attribute, :references),
old_attribute: Map.delete(old_attribute, :references),
schema: snapshot.schema,
table: snapshot.table
}
]
end
end)
remove_attribute_events =
Enum.map(attributes_to_remove, fn attribute ->
%Operation.RemoveAttribute{
attribute: attribute,
table: snapshot.table,
schema: snapshot.schema,
commented?: !opts.drop_columns
}
end)
add_attribute_events ++
alter_attribute_events ++ remove_attribute_events ++ rename_attribute_events
end
# This exists to handle the fact that the remapping of the key name -> source caused attributes
# to be considered unequal. We ignore things that only differ in that way using this function.
defp attributes_unequal?(left, right, repo) do
left = add_source_and_name_and_schema_and_ignore(left, repo)
right = add_source_and_name_and_schema_and_ignore(right, repo)
left != right
end
defp add_source_and_name_and_schema_and_ignore(attribute, repo) do
cond do
attribute[:source] ->
Map.put(attribute, :name, attribute[:source])
|> Map.update!(:source, &to_string/1)
|> Map.update!(:name, &to_string/1)
attribute[:name] ->
attribute
|> Map.put(:source, attribute[:name])
|> Map.update!(:source, &to_string/1)
|> Map.update!(:name, &to_string/1)
true ->
attribute
end
|> add_schema(repo)
|> add_ignore()
end
defp add_ignore(%{references: references} = attribute) when is_map(references) do
%{attribute | references: Map.put_new(references, :ignore?, false)}
end
defp add_ignore(attribute) do
attribute
end
defp add_schema(%{references: references} = attribute, repo) when is_map(references) do
schema = Map.get(references, :schema) || repo.config()[:default_prefix] || "public"
%{
attribute
| references: Map.put(references, :schema, schema)
}
end
defp add_schema(attribute, _) do
attribute
end
def changing_multitenancy_affects_identities?(snapshot, old_snapshot) do
snapshot.multitenancy != old_snapshot.multitenancy ||
snapshot.base_filter != old_snapshot.base_filter
end
def has_reference?(multitenancy, attribute) do
not is_nil(Map.get(attribute, :references)) and
!(attribute.references.multitenancy &&
attribute.references.multitenancy.strategy == :context &&
(is_nil(multitenancy) || multitenancy.strategy == :attribute))
end
def get_existing_snapshot(snapshot, opts) do
repo_name = snapshot.repo |> Module.split() |> List.last() |> Macro.underscore()
folder =
if snapshot.multitenancy.strategy == :context do
opts
|> snapshot_path(snapshot.repo)
|> Path.join(repo_name)
|> Path.join("tenants")
else
opts
|> snapshot_path(snapshot.repo)
|> Path.join(repo_name)
end
snapshot_folder = Path.join(folder, snapshot.table)
if File.exists?(snapshot_folder) do
snapshot_folder
|> File.ls!()
|> Enum.filter(&String.ends_with?(&1, ".json"))
|> Enum.map(&String.trim_trailing(&1, ".json"))
|> Enum.map(&Integer.parse/1)
|> Enum.filter(fn
{_int, remaining} ->
remaining == ""
:error ->
false
end)
|> Enum.map(&elem(&1, 0))
|> case do
[] ->
get_old_snapshot(folder, snapshot)
timestamps ->
timestamp = Enum.max(timestamps)
snapshot_file = Path.join(snapshot_folder, "#{timestamp}.json")
snapshot_file
|> File.read!()
|> load_snapshot()
end
else
get_old_snapshot(folder, snapshot)
end
end
defp get_old_snapshot(folder, snapshot) do
old_snapshot_file = Path.join(folder, "#{snapshot.table}.json")
# This is adapter code for the old version, where migrations were stored in a flat directory
if File.exists?(old_snapshot_file) do
old_snapshot_file
|> File.read!()
|> load_snapshot()
end
end
defp resolve_renames(_table, adding, [], _opts), do: {adding, [], []}
defp resolve_renames(_table, [], removing, _opts), do: {[], removing, []}
defp resolve_renames(table, [adding], [removing], opts) do
if renaming_to?(table, removing.source, adding.source, opts) do
{[], [], [{adding, removing}]}
else
{[adding], [removing], []}
end
end
defp resolve_renames(table, adding, [removing | rest], opts) do
{new_adding, new_removing, new_renames} =
if renaming?(table, removing, opts) do
new_attribute =
if opts.no_shell? do
raise "Unimplemented: Cannot get new_attribute without the shell!"
else
get_new_attribute(adding)
end
{adding -- [new_attribute], [], [{new_attribute, removing}]}
else
{adding, [removing], []}
end
{rest_adding, rest_removing, rest_renames} = resolve_renames(table, new_adding, rest, opts)
{new_adding ++ rest_adding, new_removing ++ rest_removing, rest_renames ++ new_renames}
end
defp renaming_to?(table, removing, adding, opts) do
if opts.no_shell? do
raise "Unimplemented: cannot determine: Are you renaming #{table}.#{removing} to #{table}.#{adding}? without shell input"
else
Mix.shell().yes?("Are you renaming #{table}.#{removing} to #{table}.#{adding}?")
end
end
defp renaming?(table, removing, opts) do
if opts.no_shell? do
raise "Unimplemented: cannot determine: Are you renaming #{table}.#{removing.source}? without shell input"
else
Mix.shell().yes?("Are you renaming #{table}.#{removing.source}?")
end
end
defp get_new_attribute(adding, tries \\ 3)
defp get_new_attribute(_adding, 0) do
raise "Could not get matching name after 3 attempts."
end
defp get_new_attribute(adding, tries) do
name =
Mix.shell().prompt(
"What are you renaming it to?: #{Enum.map_join(adding, ", ", & &1.source)}"
)
name =
if name do
String.trim(name)
else
nil
end
case Enum.find(adding, &(to_string(&1.source) == name)) do
nil -> get_new_attribute(adding, tries - 1)
new_attribute -> new_attribute
end
end
defp timestamp(require_unique? \\ false) do
# Alright, this is silly I know. But migration ids need to be unique
# and "synthesizing" that behavior is significantly more annoying than
# just waiting a bit, ensuring the migration versions are unique.
if require_unique?, do: :timer.sleep(1500)
{{y, m, d}, {hh, mm, ss}} = :calendar.universal_time()
"#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}"
end
defp pad(i) when i < 10, do: <<?0, ?0 + i>>
defp pad(i), do: to_string(i)
def get_snapshots(resource, all_resources) do
Code.ensure_compiled!(AshPostgres.DataLayer.Info.repo(resource))
if AshPostgres.DataLayer.Info.polymorphic?(resource) do
all_resources
|> Enum.flat_map(&Ash.Resource.Info.relationships/1)
|> Enum.filter(&(&1.destination == resource))
|> Enum.reject(&(&1.type == :belongs_to))
|> Enum.filter(& &1.context[:data_layer][:table])
|> Enum.uniq()
|> Enum.map(fn relationship ->
resource
|> do_snapshot(
relationship.context[:data_layer][:table],
relationship.context[:data_layer][:schema]
)
|> Map.update!(:identities, fn identities ->
identity_index_names = AshPostgres.DataLayer.Info.identity_index_names(resource)
Enum.map(identities, fn identity ->
Map.put(
identity,
:index_name,
identity_index_names[identity.name] ||
"#{relationship.context[:data_layer][:table]}_#{identity.name}_index"
)
end)
end)
|> Map.update!(:attributes, fn attributes ->
Enum.map(attributes, fn attribute ->
destination_attribute_source =
relationship.destination
|> Ash.Resource.Info.attribute(relationship.destination_attribute)
|> Map.get(:source)
if attribute.source == destination_attribute_source do
source_attribute =
Ash.Resource.Info.attribute(relationship.source, relationship.source_attribute)
Map.put(attribute, :references, %{
destination_attribute: source_attribute.source,
destination_attribute_default:
default(
source_attribute,
relationship.destination,
AshPostgres.DataLayer.Info.repo(relationship.destination)
),
destination_attribute_generated: source_attribute.generated?,
multitenancy: multitenancy(relationship.source),
table: AshPostgres.DataLayer.Info.table(relationship.source),
schema: AshPostgres.DataLayer.Info.schema(relationship.source),
on_delete: AshPostgres.DataLayer.Info.polymorphic_on_delete(relationship.source),
on_update: AshPostgres.DataLayer.Info.polymorphic_on_update(relationship.source),
name:
AshPostgres.DataLayer.Info.polymorphic_name(relationship.source) ||
"#{relationship.context[:data_layer][:table]}_#{destination_attribute_source}_fkey"
})
else
attribute
end
end)
end)
end)
else
[do_snapshot(resource, AshPostgres.DataLayer.Info.table(resource))]
end
end
defp do_snapshot(resource, table, schema \\ nil) do
snapshot = %{
attributes: attributes(resource, table),
identities: identities(resource),
table: table || AshPostgres.DataLayer.Info.table(resource),
schema: schema || AshPostgres.DataLayer.Info.schema(resource),
check_constraints: check_constraints(resource),
custom_indexes: custom_indexes(resource),
custom_statements: custom_statements(resource),
repo: AshPostgres.DataLayer.Info.repo(resource),
multitenancy: multitenancy(resource),
base_filter: AshPostgres.DataLayer.Info.base_filter_sql(resource),
has_create_action: has_create_action?(resource)
}
hash =
:sha256
|> :crypto.hash(inspect(snapshot))
|> Base.encode16()
Map.put(snapshot, :hash, hash)
end
defp has_create_action?(resource) do
resource
|> Ash.Resource.Info.actions()
|> Enum.any?(&(&1.type == :create))
end
defp check_constraints(resource) do
resource
|> AshPostgres.DataLayer.Info.check_constraints()
|> Enum.filter(& &1.check)
|> case do
[] ->
[]
constraints ->
base_filter = Ash.Resource.Info.base_filter(resource)
if base_filter && !AshPostgres.DataLayer.Info.base_filter_sql(resource) do
raise """
Cannot create a check constraint for a resource with a base filter without also configuring `base_filter_sql`.
You must provide the `base_filter_sql` option, or manually create add the check constraint to your migrations.
"""
end
constraints
end
|> Enum.map(fn constraint ->
attributes =
constraint.attribute
|> List.wrap()
|> Enum.map(fn attribute ->
attr =
resource
|> Ash.Resource.Info.attribute(attribute)
attr.source || attr.name
end)
%{
name: constraint.name,
attribute: attributes,
check: constraint.check,
base_filter: AshPostgres.DataLayer.Info.base_filter_sql(resource)
}
end)
end
defp custom_indexes(resource) do
resource
|> AshPostgres.DataLayer.Info.custom_indexes()
|> Enum.map(fn custom_index ->
Map.from_struct(custom_index)
end)
end
defp custom_statements(resource) do
resource
|> AshPostgres.DataLayer.Info.custom_statements()
|> Enum.map(fn custom_statement ->
Map.from_struct(custom_statement)
end)
end
defp multitenancy(resource) do
strategy = Ash.Resource.Info.multitenancy_strategy(resource)
attribute = Ash.Resource.Info.multitenancy_attribute(resource)
global = Ash.Resource.Info.multitenancy_global?(resource)
%{
strategy: strategy,
attribute: attribute,
global: global
}
end
defp attributes(resource, table) do
repo = AshPostgres.DataLayer.Info.repo(resource)
resource
|> Ash.Resource.Info.attributes()
|> Enum.map(
&Map.take(&1, [:name, :source, :type, :default, :allow_nil?, :generated?, :primary_key?])
)
|> Enum.map(fn attribute ->
default = default(attribute, resource, repo)
type =
AshPostgres.DataLayer.Info.migration_types(resource)[attribute.name] ||
migration_type(attribute.type)
type =
if :erlang.function_exported(repo, :override_migration_type, 1) do
repo.override_migration_type(type)
else
type
end
{type, size} =
case type do
{:varchar, size} ->
{:varchar, size}
{:binary, size} ->
{:binary, size}
other ->
{other, nil}
end
attribute
|> Map.put(:default, default)
|> Map.put(:size, size)
|> Map.put(:type, type)
|> Map.put(:source, attribute.source || attribute.name)
|> Map.delete(:name)
end)
|> Enum.map(fn attribute ->
references = find_reference(resource, table, attribute)
Map.put(attribute, :references, references)
end)
end
defp find_reference(resource, table, attribute) do
Enum.find_value(Ash.Resource.Info.relationships(resource), fn relationship ->
source_attribute_name =
relationship.source
|> Ash.Resource.Info.attribute(relationship.source_attribute)
|> then(fn attribute ->
attribute.source || attribute.name
end)
if attribute.source == source_attribute_name && relationship.type == :belongs_to &&
foreign_key?(relationship) do
configured_reference =
configured_reference(resource, table, attribute.source || attribute.name, relationship)
unless Map.get(configured_reference, :ignore?) do
destination_attribute_source =
relationship.destination
|> Ash.Resource.Info.attribute(relationship.destination_attribute)
|> then(fn attribute ->
attribute.source || attribute.name
end)
%{
destination_attribute: destination_attribute_source,
multitenancy: multitenancy(relationship.destination),
on_delete: configured_reference.on_delete,
on_update: configured_reference.on_update,
name: configured_reference.name,
schema:
relationship.context[:data_layer][:schema] ||
AshPostgres.DataLayer.Info.schema(relationship.destination) ||
AshPostgres.DataLayer.Info.repo(relationship.destination).config()[
:default_prefix
],
table:
relationship.context[:data_layer][:table] ||
AshPostgres.DataLayer.Info.table(relationship.destination)
}
end
end
end)
end
defp configured_reference(resource, table, attribute, relationship) do
ref =
resource
|> AshPostgres.DataLayer.Info.references()
|> Enum.find(&(&1.relationship == relationship.name))
|> Kernel.||(%{
on_delete: nil,
on_update: nil,
schema:
relationship.context[:data_layer][:schema] ||
AshPostgres.DataLayer.Info.schema(relationship.destination) ||
AshPostgres.DataLayer.Info.repo(relationship.destination).config()[:default_prefix],
name: nil,
ignore?: false
})
Map.put(ref, :name, ref.name || "#{table}_#{attribute}_fkey")
end
defp migration_type({:array, type}), do: {:array, migration_type(type)}
defp migration_type(Ash.Type.CiString), do: :citext
defp migration_type(Ash.Type.UUID), do: :uuid
defp migration_type(Ash.Type.Integer), do: :bigint
defp migration_type(other), do: migration_type_from_storage_type(Ash.Type.storage_type(other))
defp migration_type_from_storage_type(:string), do: :text
defp migration_type_from_storage_type(storage_type), do: storage_type
defp foreign_key?(relationship) do
Ash.DataLayer.data_layer(relationship.source) == AshPostgres.DataLayer &&
AshPostgres.DataLayer.Info.repo(relationship.source) ==
AshPostgres.DataLayer.Info.repo(relationship.destination)
end
defp identities(resource) do
identity_index_names = AshPostgres.DataLayer.Info.identity_index_names(resource)
resource
|> Ash.Resource.Info.identities()
|> case do
[] ->
[]
identities ->
base_filter = Ash.Resource.Info.base_filter(resource)
if base_filter && !AshPostgres.DataLayer.Info.base_filter_sql(resource) do
raise """
Cannot create a unique index for a resource with a base filter without also configuring `base_filter_sql`.
You must provide the `base_filter_sql` option, or skip unique indexes with `skip_unique_indexes`"
"""
end
identities
end
|> Enum.reject(fn identity ->
identity.name in AshPostgres.DataLayer.Info.skip_unique_indexes(resource)
end)
|> Enum.filter(fn identity ->
Enum.all?(identity.keys, fn key ->
Ash.Resource.Info.attribute(resource, key)
end)
end)
|> Enum.sort_by(& &1.name)
|> Enum.map(&Map.take(&1, [:name, :keys]))
|> Enum.map(fn %{keys: keys} = identity ->
%{
identity
| keys:
Enum.map(keys, fn key ->
attribute = Ash.Resource.Info.attribute(resource, key)
attribute.source || attribute.name
end)
}
end)
|> Enum.map(fn identity ->
Map.put(
identity,
:index_name,
identity_index_names[identity.name] ||
"#{AshPostgres.DataLayer.Info.table(resource)}_#{identity.name}_index"
)
end)
|> Enum.map(&Map.put(&1, :base_filter, AshPostgres.DataLayer.Info.base_filter_sql(resource)))
end
@uuid_functions [&Ash.UUID.generate/0, &Ecto.UUID.generate/0]
defp default(%{name: name, default: default}, resource, repo) when is_function(default) do
configured_default(resource, name) ||
cond do
default in @uuid_functions && "uuid-ossp" in (repo.config()[:installed_extensions] || []) ->
~S[fragment("uuid_generate_v4()")]
default == (&DateTime.utc_now/0) ->
~S[fragment("now()")]
true ->
"nil"
end
end
defp default(%{name: name, default: {_, _, _}}, resource, _),
do: configured_default(resource, name) || "nil"
defp default(%{name: name, default: nil}, resource, _),
do: configured_default(resource, name) || "nil"
defp default(%{name: name, default: value}, resource, _),
do: configured_default(resource, name) || EctoMigrationDefault.to_default(value)
defp configured_default(resource, attribute) do
AshPostgres.DataLayer.Info.migration_defaults(resource)[attribute]
end
defp snapshot_to_binary(snapshot) do
snapshot
|> Map.update!(:attributes, fn attributes ->
Enum.map(attributes, fn attribute ->
%{attribute | type: sanitize_type(attribute.type, attribute[:size])}
end)
end)
|> Jason.encode!(pretty: true)
end
defp sanitize_type({:array, type}, size) do
["array", sanitize_type(type, size)]
end
defp sanitize_type(:varchar, size) when not is_nil(size) do
["varchar", size]
end
defp sanitize_type(:binary, size) when not is_nil(size) do
["binary", size]
end
defp sanitize_type(type, _) do
type
end
defp load_snapshot(json) do
json
|> Jason.decode!(keys: :atoms!)
|> sanitize_snapshot()
end
defp sanitize_snapshot(snapshot) do
snapshot
|> Map.put_new(:has_create_action, true)
|> Map.put_new(:schema, nil)
|> Map.update!(:identities, fn identities ->
Enum.map(identities, &load_identity(&1, snapshot.table))
end)
|> Map.update!(:attributes, fn attributes ->
Enum.map(attributes, fn attribute ->
attribute = load_attribute(attribute, snapshot.table)
if is_map(Map.get(attribute, :references)) do
%{
attribute
| references: rewrite(attribute.references, :ignore, :ignore?)
}
else
attribute
end
end)
end)
|> Map.put_new(:custom_indexes, [])
|> Map.update!(:custom_indexes, &load_custom_indexes/1)
|> Map.put_new(:custom_statements, [])
|> Map.update!(:custom_statements, &load_custom_statements/1)
|> Map.put_new(:check_constraints, [])
|> Map.update!(:check_constraints, &load_check_constraints/1)
|> Map.update!(:repo, &String.to_atom/1)
|> Map.put_new(:multitenancy, %{
attribute: nil,
strategy: nil,
global: nil
})
|> Map.update!(:multitenancy, &load_multitenancy/1)
|> Map.put_new(:base_filter, nil)
end
defp load_check_constraints(constraints) do
Enum.map(constraints, fn constraint ->
Map.update!(constraint, :attribute, fn attribute ->
attribute
|> List.wrap()
|> Enum.map(&String.to_atom/1)
end)
end)
end
defp load_custom_indexes(custom_indexes) do
Enum.map(custom_indexes || [], fn custom_index ->
custom_index
|> Map.put_new(:fields, [])
|> Map.put_new(:include, [])
|> Map.put_new(:message, nil)
end)
end
defp load_custom_statements(statements) do
Enum.map(statements || [], fn statement ->
Map.update!(statement, :name, &String.to_atom/1)
end)
end
defp load_multitenancy(multitenancy) do
multitenancy
|> Map.update!(:strategy, fn strategy -> strategy && String.to_atom(strategy) end)
|> Map.update!(:attribute, fn attribute -> attribute && String.to_atom(attribute) end)
end
defp load_attribute(attribute, table) do
type = load_type(attribute.type)
{type, size} =
case type do
{:varchar, size} ->
{:varchar, size}
{:binary, size} ->
{:binary, size}
other ->
{other, nil}
end
attribute =
if Map.has_key?(attribute, :name) do
Map.put(attribute, :source, String.to_atom(attribute.name))
else
Map.update!(attribute, :source, &String.to_atom/1)
end
attribute
|> Map.put(:type, type)
|> Map.put(:size, size)
|> Map.put_new(:default, "nil")
|> Map.update!(:default, &(&1 || "nil"))
|> Map.update!(:references, fn
nil ->
nil
references ->
references
|> rewrite(
destination_field: :destination_attribute,
destination_field_default: :destination_attribute_default,
destination_field_generated: :destination_attribute_generated
)
|> Map.delete(:ignore)
|> rewrite(:ignore?, :ignore)
|> Map.update!(:destination_attribute, &String.to_atom/1)
|> Map.put_new(:schema, nil)
|> Map.put_new(:destination_attribute_default, "nil")
|> Map.put_new(:destination_attribute_generated, false)
|> Map.put_new(:on_delete, nil)
|> Map.put_new(:on_update, nil)
|> Map.update!(:on_delete, &(&1 && String.to_atom(&1)))
|> Map.update!(:on_update, &(&1 && String.to_atom(&1)))
|> Map.put(
:name,
Map.get(references, :name) || "#{table}_#{attribute.source}_fkey"
)
|> Map.put_new(:multitenancy, %{
attribute: nil,
strategy: nil,
global: nil
})
|> Map.update!(:multitenancy, &load_multitenancy/1)
|> sanitize_name(table)
end)
end
defp rewrite(map, keys) do
Enum.reduce(keys, map, fn {key, to}, map ->
rewrite(map, key, to)
end)
end
defp rewrite(map, key, to) do
if Map.has_key?(map, key) do
map
|> Map.put(to, Map.get(map, key))
|> Map.delete(key)
else
map
end
end
defp sanitize_name(reference, table) do
if String.starts_with?(reference.name, "_") do
Map.put(reference, :name, "#{table}#{reference.name}")
else
reference
end
end
defp load_type(["array", type]) do
{:array, load_type(type)}
end
defp load_type(["varchar", size]) do
{:varchar, size}
end
defp load_type(["binary", size]) do
{:binary, size}
end
defp load_type(type) do
String.to_atom(type)
end
defp load_identity(identity, table) do
identity
|> Map.update!(:name, &String.to_atom/1)
|> Map.update!(:keys, fn keys ->
keys
|> Enum.map(&String.to_atom/1)
|> Enum.sort()
end)
|> add_index_name(table)
|> Map.put_new(:base_filter, nil)
end
defp add_index_name(%{name: name} = index, table) do
Map.put_new(index, :index_name, "#{table}_#{name}_unique_index")
end
end