# SPDX-FileCopyrightText: 2019 ash_postgres contributors # # SPDX-License-Identifier: MIT if Code.ensure_loaded?(Igniter) do defmodule AshPostgres.ResourceGenerator do @moduledoc false alias AshPostgres.ResourceGenerator.Spec require Logger def generate(igniter, repos, domain, opts \\ []) do {igniter, resources} = Ash.Resource.Igniter.list_resources(igniter) # This is a hack. We should be looking at compiled resources # unlikely to ever matter given how this task will be used though. resources = Enum.filter(resources, &Code.ensure_loaded?/1) igniter = Igniter.include_all_elixir_files(igniter) opts = handle_csv_opts(opts, [:tables, :skip_tables, :extend]) opts = Keyword.update(opts, :tables, nil, fn tables -> if tables == [] do nil else tables end end) opts = Keyword.update(opts, :skip_tables, nil, fn tables -> if tables == [] do [] else tables end |> Enum.concat(["schema_migrations"]) end) specs = repos |> Enum.flat_map( &Spec.tables(&1, skip_tables: opts[:skip_tables], tables: opts[:tables], skip_unknown: opts[:skip_unknown], include_views: opts[:include_views] ) ) |> Enum.map(fn %{table_name: table} = spec -> resource = table |> Igniter.Inflex.singularize() |> Macro.camelize() |> then(&Module.concat([domain, &1])) %{spec | resource: resource} end) |> Enum.group_by(& &1.resource) |> Enum.map(fn {_resource, [single]} -> single {resource, specs} -> raise """ Duplicate resource names detected across multiple repos: #{inspect(resource)} #{inspect(Enum.map(specs, & &1.repo))} To address this, run this command separately for each repo and specify the `--domain` option to put the resources into a separate domain, or omit the table with `--tables` or `--skip-tables` """ end) |> Spec.add_relationships(resources, opts) Enum.reduce(specs, igniter, fn table_spec, igniter -> if opts[:fragments] do table_to_resource_with_fragment(igniter, table_spec, domain, opts) else table_to_resource(igniter, table_spec, domain, opts) end end) end defp handle_csv_opts(opts, keys) do Enum.reduce(keys, opts, fn key, opts -> opts |> Keyword.get_values(key) |> case do [] -> opts values -> values |> Enum.join(",") |> String.split(",", trim: true) |> then(&Keyword.put(opts, key, &1)) end end) end defp table_to_resource( igniter, %AshPostgres.ResourceGenerator.Spec{} = table_spec, domain, opts ) do resource = """ use Ash.Resource, domain: #{inspect(domain)}, data_layer: AshPostgres.DataLayer #{resource_block(table_spec)} #{default_actions(opts, table_spec)} postgres do table #{inspect(table_spec.table_name)} repo #{inspect(table_spec.repo)} #{schema_option(table_spec)} #{migrate_option(table_spec, opts)} #{references(table_spec, opts[:no_migrations])} #{custom_indexes(table_spec, opts[:no_migrations])} #{check_constraints(table_spec, opts[:no_migrations])} #{skip_unique_indexes(table_spec)} #{identity_index_names(table_spec)} end attributes do #{attributes(table_spec, opts)} end """ |> add_identities(table_spec) |> add_relationships(table_spec, opts) igniter |> Ash.Domain.Igniter.add_resource_reference(domain, table_spec.resource) |> Igniter.Project.Module.create_module(table_spec.resource, resource) |> then(fn igniter -> if opts[:extend] && opts[:extend] != [] do Igniter.compose_task(igniter, "ash.patch.extend", [ table_spec.resource | opts[:extend] || [] ]) else igniter end end) end defp table_to_resource_with_fragment( igniter, %AshPostgres.ResourceGenerator.Spec{} = table_spec, domain, opts ) do fragment_module = Module.concat(table_spec.resource, Model) fragment_content = """ use Spark.Dsl.Fragment, of: Ash.Resource #{attributes_block(table_spec, opts)} #{identities_block(table_spec)} #{relationships_block(table_spec, opts)} """ resource_path = Igniter.Project.Module.proper_location(igniter, table_spec.resource) resource_exists? = Rewrite.has_source?(igniter.rewrite, resource_path) || Igniter.exists?(igniter, resource_path) if resource_exists? do # Only create/update the fragment file Igniter.Project.Module.create_module(igniter, fragment_module, fragment_content) else resource_content = """ use Ash.Resource, domain: #{inspect(domain)}, data_layer: AshPostgres.DataLayer, fragments: [#{inspect(fragment_module)}] #{resource_block(table_spec)} #{default_actions(opts, table_spec)} postgres do table #{inspect(table_spec.table_name)} repo #{inspect(table_spec.repo)} #{schema_option(table_spec)} #{migrate_option(table_spec, opts)} #{references(table_spec, opts[:no_migrations])} #{custom_indexes(table_spec, opts[:no_migrations])} #{check_constraints(table_spec, opts[:no_migrations])} #{skip_unique_indexes(table_spec)} #{identity_index_names(table_spec)} end """ igniter |> Igniter.Project.Module.create_module(fragment_module, fragment_content) |> Igniter.Project.Module.create_module(table_spec.resource, resource_content) |> Ash.Domain.Igniter.add_resource_reference(domain, table_spec.resource) |> then(fn igniter -> if opts[:extend] && opts[:extend] != [] do Igniter.compose_task(igniter, "ash.patch.extend", [ table_spec.resource | opts[:extend] || [] ]) else igniter end end) end end defp attributes_block(table_spec, opts) do """ attributes do #{attributes(table_spec, opts)} end """ end defp identities_block(%{indexes: indexes}) do indexes |> Enum.filter(fn %{unique?: unique?, columns: columns} -> unique? && Enum.all?(columns, &Regex.match?(~r/^[0-9a-zA-Z_]+$/, &1)) end) |> Enum.map(fn index -> name = index.identity_name fields = "[" <> Enum.map_join(index.columns, ", ", &":#{&1}") <> "]" case identity_options(index) do "" -> "identity :#{name}, #{fields}" options -> """ identity :#{name}, #{fields} do #{options} end """ end end) |> case do [] -> "" identities -> """ identities do #{Enum.join(identities, "\n")} end """ end end defp relationships_block(%{relationships: []}, _opts), do: "" defp relationships_block(%{relationships: relationships} = spec, opts) do relationships |> Enum.sort_by(&relationship_sort_key/1) |> Enum.map_join("\n", fn relationship -> case relationship_options(spec, relationship, opts) do "" -> "#{relationship.type} :#{relationship.name}, #{inspect(relationship.destination)}" options -> """ #{relationship.type} :#{relationship.name}, #{inspect(relationship.destination)} do #{options} end """ end end) |> then(fn rels -> """ relationships do #{rels} end """ end) end defp relationship_sort_key(%{type: :belongs_to, name: name}), do: {0, name} defp relationship_sort_key(%{type: :has_one, name: name}), do: {1, name} defp relationship_sort_key(%{type: :has_many, name: name}), do: {2, name} defp relationship_sort_key(%{type: :many_to_many, name: name}), do: {3, name} defp schema_option(%{schema: schema}) when schema != "public" do "schema #{inspect(schema)}" end defp schema_option(_), do: "" defp no_primary_key?(%AshPostgres.ResourceGenerator.Spec{attributes: attributes}) do Enum.all?(attributes, &(not &1.primary_key?)) end defp view?(%AshPostgres.ResourceGenerator.Spec{kind: kind}), do: kind in [:view, :materialized_view] defp view_kind_label(%AshPostgres.ResourceGenerator.Spec{kind: :view}), do: "VIEW" defp view_kind_label(%AshPostgres.ResourceGenerator.Spec{kind: :materialized_view}), do: "MATERIALIZED VIEW" defp resource_block(table_spec) do if no_primary_key?(table_spec) do """ resource do # WARNING: Configured to bypass missing primary key. # Add primary_key?: true to your attributes/relationships and remove this block. require_primary_key? false end """ else "" end end defp default_actions(opts, table_spec) do cond do view?(table_spec) -> """ actions do # WARNING: Generated from a PostgreSQL #{view_kind_label(table_spec)}. # Views are read-only; only the :read default action is safe. defaults [:read] end """ no_primary_key?(table_spec) -> """ actions do # WARNING: No primary key detected. # :update and :destroy actions require a primary key to safely identify records. defaults [:read, create: :*] end """ opts[:default_actions] -> """ actions do defaults [:read, :destroy, create: :*, update: :*] end """ true -> "" end end defp migrate_option(table_spec, opts) do cond do view?(table_spec) -> """ # NOTE: Source is a PostgreSQL #{view_kind_label(table_spec)}, not a base table. # migrate? false prevents Ash from trying to manage its schema. # TODO: Migrations need to be handled manually for views. migrate? false """ opts[:no_migrations] -> "migrate? false" true -> "" end end defp check_constraints(%{check_constraints: _check_constraints}, true) do "" end defp check_constraints(%{check_constraints: []}, _) do "" end defp check_constraints(%{check_constraints: check_constraints}, _) do check_constraints = Enum.map_join(check_constraints, "\n", fn check_constraint -> """ check_constraint :#{check_constraint.column}, "#{check_constraint.name}", check: "#{check_constraint.expression}", message: "is invalid" """ end) """ check_constraints do #{check_constraints} end """ end defp skip_unique_indexes(%{indexes: indexes}) do indexes |> Enum.filter(fn %{unique?: unique?, columns: columns} -> unique? && Enum.all?(columns, &Regex.match?(~r/^[0-9a-zA-Z_]+$/, &1)) end) |> Enum.reject(&index_as_identity?/1) |> case do [] -> "" indexes -> """ skip_unique_indexes [#{Enum.map_join(indexes, ",", &":#{&1.identity_name}")}] """ end end defp identity_index_names(%{indexes: indexes}) do indexes |> Enum.filter(fn %{unique?: unique?, columns: columns} -> unique? && Enum.all?(columns, &Regex.match?(~r/^[0-9a-zA-Z_]+$/, &1)) end) |> case do [] -> [] indexes -> indexes |> Enum.map_join(", ", fn index -> "#{index.identity_name}: \"#{index.name}\"" end) |> then(&"identity_index_names [#{&1}]") end end defp add_identities(str, %{indexes: indexes}) do indexes |> Enum.filter(fn %{unique?: unique?, columns: columns} -> unique? && Enum.all?(columns, &Regex.match?(~r/^[0-9a-zA-Z_]+$/, &1)) end) |> Enum.map(fn index -> name = index.identity_name fields = "[" <> Enum.map_join(index.columns, ", ", &":#{&1}") <> "]" case identity_options(index) do "" -> "identity :#{name}, #{fields}" options -> """ identity :#{name}, #{fields} do #{options} end """ end end) |> case do [] -> str identities -> """ #{str} identities do #{Enum.join(identities, "\n")} end """ end end defp identity_options(index) do "" |> add_identity_where(index) |> add_nils_distinct?(index) end defp add_identity_where(str, %{where_clause: nil}), do: str defp add_identity_where(str, %{name: name, where_clause: where_clause}) do Logger.warning(""" Index #{name} has been left commented out in its resource Manual conversion of `#{where_clause}` to an Ash expression is required. """) """ #{str} # Express `#{where_clause}` as an Ash expression # where expr(...) """ end defp add_nils_distinct?(str, %{nils_distinct?: false}) do "#{str}\n nils_distinct? false" end defp add_nils_distinct?(str, _), do: str defp add_relationships(str, %{relationships: []}, _opts) do str end defp add_relationships(str, %{relationships: relationships} = spec, opts) do relationships |> Enum.sort_by(&relationship_sort_key/1) |> Enum.map_join("\n", fn relationship -> case relationship_options(spec, relationship, opts) do "" -> "#{relationship.type} :#{relationship.name}, #{inspect(relationship.destination)}" options -> """ #{relationship.type} :#{relationship.name}, #{inspect(relationship.destination)} do #{options} end """ end end) |> then(fn rels -> """ #{str} relationships do #{rels} end """ end) end defp relationship_options(spec, %{type: :belongs_to} = rel, opts) do case Enum.find(spec.attributes, fn attribute -> attribute.name == rel.source_attribute end) do %{ default: default, generated?: generated?, source: source, name: name } when not is_nil(default) or generated? or source != name -> "define_attribute? false" |> add_destination_attribute(rel, "id") |> add_source_attribute(rel, "#{rel.name}_id") |> add_allow_nil(rel) |> add_filter(rel) |> add_public(opts) attribute -> "" |> add_destination_attribute(rel, "id") |> add_source_attribute(rel, "#{rel.name}_id") |> add_allow_nil(rel) |> add_primary_key(attribute) |> add_attribute_type(attribute) |> add_filter(rel) |> add_public(opts) end end defp relationship_options(_spec, %{type: :many_to_many} = rel, opts) do default_source_on_join = module_to_id_string(rel.source) default_dest_on_join = module_to_id_string(rel.destination) "" |> add_through(rel) |> add_join_relationship(rel) |> add_source_attribute_on_join_resource(rel, default_source_on_join) |> add_destination_attribute_on_join_resource(rel, default_dest_on_join) |> add_public(opts) end defp relationship_options(_spec, rel, opts) do default_destination_attribute = module_to_id_string(rel.source) "" |> add_destination_attribute(rel, default_destination_attribute) |> add_source_attribute(rel, "id") |> add_filter(rel) |> add_public(opts) end defp module_to_id_string(module) do module |> Module.split() |> List.last() |> Macro.underscore() |> Kernel.<>("_id") end defp add_filter(str, %{match_with: []}), do: str defp add_filter(str, %{match_with: match_with}) do filter = Enum.map_join(match_with, " and ", fn {source, dest} -> "parent(#{source}) == #{dest}" end) "#{str}\n filter expr(#{filter})" end defp add_through(str, %{through: through}) do "#{str}\n through #{inspect(through)}" end defp add_join_relationship(str, %{join_relationship: name}) do "#{str}\n join_relationship :#{name}" end defp add_source_attribute_on_join_resource(str, rel, default) do if rel.source_attribute_on_join_resource == default do str else "#{str}\n source_attribute_on_join_resource :#{rel.source_attribute_on_join_resource}" end end defp add_destination_attribute_on_join_resource(str, rel, default) do if rel.destination_attribute_on_join_resource == default do str else "#{str}\n destination_attribute_on_join_resource :#{rel.destination_attribute_on_join_resource}" end end defp add_attribute_type(str, %{attr_type: :uuid}), do: str defp add_attribute_type(str, %{attr_type: attr_type}) do "#{str}\n attribute_type :#{attr_type}" end defp add_destination_attribute(str, rel, default) do if rel.destination_attribute == default do str else "#{str}\n destination_attribute :#{rel.destination_attribute}" end end defp add_source_attribute(str, rel, default) do if rel.source_attribute == default do str else "#{str}\n source_attribute :#{rel.source_attribute}" end end defp references(_table_spec, true) do "" end defp references(table_spec, _) do table_spec.foreign_keys |> Enum.flat_map(fn %Spec.ForeignKey{} = foreign_key -> default_name = "#{table_spec.table_name}_#{foreign_key.column}_fkey" if default_name == foreign_key.constraint_name and foreign_key.on_update == "NO ACTION" and foreign_key.on_delete == "NO ACTION" and foreign_key.match_type in ["SIMPLE", "NONE"] do [] else relationship = Enum.find(table_spec.relationships, fn relationship -> relationship.type == :belongs_to and relationship.constraint_name == foreign_key.constraint_name end) if relationship do name = relationship.name options = "" |> add_on(:update, foreign_key.on_update) |> add_on(:delete, foreign_key.on_delete) |> add_match_with(foreign_key.match_with) |> add_match_type(foreign_key.match_type) [ {name, """ reference :#{name} do #{options} end """} ] else [] end end end) |> Enum.sort_by(fn {name, _ref} -> name end) |> Enum.map(fn {_name, ref} -> ref end) |> case do [] -> [] refs -> refs |> Enum.join("\n") |> String.trim() |> then( &[ """ references do #{&1} end """ ] ) end end defp add_match_with(str, empty) when empty in [[], nil], do: str defp add_match_with(str, keyval), do: str <> "\nmatch_with [#{Enum.map_join(keyval, fn {key, val} -> "#{key}: :#{val}" end)}]" defp add_match_type(str, type) when type in ["SIMPLE", "NONE"], do: str defp add_match_type(str, "FULL"), do: str <> "\nmatch_type :full" defp add_match_type(str, "PARTIAL"), do: str <> "\nmatch_type :partial" defp add_on(str, type, "RESTRICT"), do: str <> "\non_#{type} :restrict" defp add_on(str, type, "CASCADE"), do: str <> "\non_#{type} :#{type}" defp add_on(str, type, "SET NULL"), do: str <> "\non_#{type} :nilify" defp add_on(str, _type, _), do: str defp custom_indexes(table_spec, true) do table_spec.indexes |> Enum.reject(fn index -> !index.unique? || (&index_as_identity?/1) || Enum.any?(index.columns, &String.contains?(&1, "(")) end) |> case do [] -> "" indexes -> indexes |> Enum.map_join(", ", fn %{index: name, columns: columns} -> columns = Enum.map_join(columns, ", ", &":#{&1}") "{[#{columns}], #{inspect(name)}}" end) |> then(fn index_names -> "unique_index_names [#{index_names}]" end) end end defp custom_indexes(table_spec, _) do table_spec.indexes |> Enum.reject(&index_as_identity?/1) |> case do [] -> "" indexes -> indexes |> Enum.map_join("\n", fn index -> columns = index.columns |> Enum.map_join(", ", fn thing -> if String.contains?(thing, "(") do inspect(thing) else Enum.at(String.split(":#{thing}", " "), 0) end end) case index_options(table_spec, index) do "" -> "index [#{columns}]" options -> """ index [#{columns}] do #{options} end """ end end) |> then(fn indexes -> """ custom_indexes do #{indexes} end """ end) end end defp index_as_identity?(index) do is_nil(index.where_clause) and index.using == "btree" and index.include in [nil, []] and Enum.all?(index.columns, &Regex.match?(~r/^[0-9a-zA-Z_]+$/, &1)) end defp index_options(spec, index) do default_name = if Enum.all?(index.columns, &Regex.match?(~r/^[0-9a-zA-Z_]+$/, &1)) do AshPostgres.CustomIndex.name(spec.table_name, %{fields: index.columns}) end "" |> add_index_name(index.name, default_name) |> add_unique(index.unique?) |> add_using(index.using) |> add_where(index.where_clause) |> add_include(index.include) |> add_nulls_distinct(index.nulls_distinct) end defp add_index_name(str, default, default), do: str defp add_index_name(str, name, _), do: str <> "\nname #{inspect(name)}" defp add_unique(str, false), do: str defp add_unique(str, true), do: str <> "\nunique true" defp add_nulls_distinct(str, true), do: str defp add_nulls_distinct(str, false), do: str <> "\nnulls_distinct false" defp add_using(str, "btree"), do: str defp add_using(str, using), do: str <> "\nusing #{inspect(using)}" defp add_where(str, empty) when empty in [nil, ""], do: str defp add_where(str, where), do: str <> "\nwhere #{inspect(where)}" defp add_include(str, empty) when empty in [nil, []], do: str defp add_include(str, include), do: str <> "\ninclude [#{Enum.map_join(include, ", ", &inspect/1)}]" defp attributes(table_spec, opts) do table_spec.attributes |> Enum.split_with(& &1.default) |> then(fn {l, r} -> r ++ l end) |> Enum.split_with(& &1.primary_key?) |> then(fn {l, r} -> l ++ r end) |> Enum.filter(fn attribute -> if not is_nil(attribute.default) or !!attribute.generated? or attribute.source != attribute.name do true else not Enum.any?(table_spec.relationships, fn relationship -> relationship.type == :belongs_to and relationship.source_attribute == attribute.name end) end end) |> Enum.map_join("\n", &attribute(&1, opts)) end defp attribute(attribute, opts) do now_default = &DateTime.utc_now/0 uuid_default = &Ash.UUID.generate/0 {constructor, attribute, type?, type_option?} = case attribute do %{name: "updated_at", attr_type: attr_type} -> {"update_timestamp", %{attribute | default: nil, generated?: false}, false, attr_type != :utc_datetime_usec} %{default: default, attr_type: attr_type} when default == now_default -> {"create_timestamp", %{attribute | default: nil, generated?: false}, false, attr_type != :utc_datetime_usec} %{default: default, attr_type: attr_type, primary_key?: true} when default == uuid_default -> {"uuid_primary_key", %{ attribute | default: nil, primary_key?: false, generated?: false, allow_nil?: true }, false, attr_type != :uuid} _ -> {"attribute", attribute, true, false} end case String.trim(options(attribute, type_option?, opts)) do "" -> if type? do "#{constructor} :#{attribute.name}, #{inspect(attribute.attr_type)}" else "#{constructor} :#{attribute.name}" end options -> if type? do """ #{constructor} :#{attribute.name}, #{inspect(attribute.attr_type)} do #{options} end """ else """ #{constructor} :#{attribute.name} do #{options} end """ end end end defp options(attribute, type_option?, opts) do "" |> add_primary_key(attribute) |> add_allow_nil(attribute) |> add_sensitive(attribute) |> add_default(attribute) |> add_type(attribute, type_option?) |> add_generated(attribute) |> add_public(opts) |> add_source(attribute) end defp add_public(str, options) do if options[:public] do str <> "\n public? true" else str end end defp add_type(str, %{attr_type: attr_type}, true) do str <> "\n type #{inspect(attr_type)}" end defp add_type(str, _, _), do: str defp add_generated(str, %{generated?: true}) do str <> "\n generated? true" end defp add_generated(str, _), do: str defp add_source(str, %{name: name, source: source}) when name != source do str <> "\n source :#{source}" end defp add_source(str, _), do: str defp add_primary_key(str, %{primary_key?: true}) do str <> "\n primary_key? true" end defp add_primary_key(str, _), do: str defp add_allow_nil(str, %{allow_nil?: false}) do str <> "\n allow_nil? false" end defp add_allow_nil(str, _), do: str defp add_sensitive(str, %{sensitive?: true}) do str <> "\n sensitive? true" end defp add_sensitive(str, _), do: str defp add_default(str, %{default: default}) when not is_nil(default) do str <> "\n default #{inspect(default)}" end defp add_default(str, _), do: str end end