lib/ash/resource/verifiers/validate_relationship_attributes_match.ex

defmodule Ash.Resource.Verifiers.ValidateRelationshipAttributesMatch do
  @moduledoc """
  Shows a warning on potentially incompatible relationship attributes.
  """
  use Spark.Dsl.Verifier

  def verify(dsl) do
    module = Spark.Dsl.Verifier.get_persisted(dsl, :module)

    dsl
    |> Ash.Resource.Info.relationships()
    |> Enum.reject(&(&1.type == :many_to_many))
    |> Enum.flat_map(fn
      %{
        source_attribute: source_attribute,
        destination_attribute: destination_attribute,
        destination: destination,
        validate_destination_attribute?: true
      } = rel ->
        case Code.ensure_compiled(destination) do
          {:module, destination} ->
            [{rel, source_attribute, destination_attribute, destination}]

          _ ->
            # older versions of ash/spark/elixir can get here, but not newer ones
            []
        end

      _ ->
        []
    end)
    |> Enum.flat_map(fn {rel, source, dest, resource} ->
      source = Ash.Resource.Info.attribute(dsl, source)
      dest = Ash.Resource.Info.attribute(resource, dest)

      if source && dest && !compatible_types?(source, dest) do
        [warning(rel, module, source, dest, resource)]
      else
        []
      end
    end)
    |> case do
      [] ->
        :ok

      warnings ->
        {:warn, warnings}
    end
  end

  defp warning(rel, module, source, dest, resource) do
    """
    Source attribute may not be compatible with destination attribute for #{inspect(module)}.#{rel.name} to #{inspect(resource)}

    `source_attribute` type: `#{inspect(source.type)}`
    `destination_attribute` type: `#{inspect(dest.type)}`

    Types are considered compatible if:

    1. They are exactly the same
    2. Their `storage_type/1` callback returns the same value
    3. The storage types are `:text` and `:string`
    4. The relationship has `validate_destination_attribute?` set to `false`.
    5. They are explicitly configured as compatible. To do so in this instance, add it to your config like so:


    config :ash, :compatible_foreign_key_types,
      [
        {#{inspect(source.type)}, #{inspect(dest.type)}}
      ]
    """
  end

  defp compatible_types?(%{type: source, constraints: source_constraints}, %{
         type: dest,
         constraints: dest_constraints
       }) do
    left_storage_type = Ash.Type.storage_type(source, source_constraints)
    right_storage_type = Ash.Type.storage_type(dest, dest_constraints)

    cond do
      source == dest ->
        true

      left_storage_type == right_storage_type ->
        true

      left_storage_type == :text and right_storage_type == :string ->
        true

      left_storage_type == :string and right_storage_type == :text ->
        true

      true ->
        configured_as_compatible?(source, dest)
    end
  end

  @compatible_foreign_key_types Application.compile_env(:ash, :compatible_foreign_key_types, [])

  defp configured_as_compatible?(source, dest) do
    Enum.find(@compatible_foreign_key_types, fn {left, right} ->
      (source == left and dest == right) or (source == right and dest == left)
    end)
  end
end