lib/bylaw/ecto/query/checks/mandatory_join_keys.ex

defmodule Bylaw.Ecto.Query.Checks.MandatoryJoinKeys do
  @moduledoc """
  Validates that explicit schema joins preserve configured mandatory keys.

  This check is intentionally narrow. It handles direct schema joins.

  ## Examples

  Bad:

      from(Post, as: :post)
      |> join(:inner, [post: p], c in Comment,
        as: :comment,
        on: c.post_id == p.id
      )

  Why this is bad:

  If `:organization_id` is configured as mandatory, the join preserves the row
  relationship but not the tenant key. Bad or inconsistent foreign-key data can
  cross tenant boundaries.

  Better:

      from(Post, as: :post)
      |> join(:inner, [post: p], c in Comment,
        as: :comment,
        on:
          c.post_id == p.id and
            c.organization_id == p.organization_id
      )

  Why this is better:

  The join requires both the row relationship and the configured key to match.

  ## Notes

  This check only validates direct explicit schema joins and supported equality
  predicates in the join `on` expression. Association joins, subqueries,
  fragments, and schema-less joins are ignored.

  Association joins, subqueries, fragments, and schema-less joins are not
  validated by this check.

  Like Bylaw's other Ecto query checks, this reads Ecto query structs directly.
  Ecto treats those structs as opaque, so this check intentionally supports a
  small, tested subset of Ecto's query AST.

  ## Options

    * `:validate` - explicit `false` disables the check. Defaults to `true`.
    * `:keys` - required non-empty list of field names when the check runs.
    * `:match` - `:any` or `:all`. Defaults to `:any`.

  Example check spec:

      {Bylaw.Ecto.Query.Checks.MandatoryJoinKeys,
       keys: [:organization_id],
       match: :all}

  When a join schema does not contain any configured keys, that join is not
  applicable and the check returns no issue for it. For applicable joins, the
  check accepts direct equality predicates between the joined binding and
  another query binding in the join `on` expression.

  ## Usage

  Add this module to the explicit check list passed through `Bylaw.Ecto.Query`.
  See `Bylaw.Ecto.Query` for the full `c:Ecto.Repo.prepare_query/3` setup.
  """

  @behaviour Bylaw.Ecto.Query.Check

  alias Bylaw.Ecto.Query.CheckOptions
  alias Bylaw.Ecto.Query.Introspection
  alias Bylaw.Ecto.Query.Issue

  @typedoc false
  @type match :: :any | :all
  @typedoc false
  @type check_opts ::
          list(
            {:validate, boolean()}
            | {:keys, list(atom())}
            | {:match, match()}
          )
  @typedoc false
  @type opts :: check_opts()
  @typedoc false
  @type field_set :: list(atom())

  @doc """
  Implements the `Bylaw.Ecto.Query.Check` validation callback.
  """

  @impl Bylaw.Ecto.Query.Check
  @spec validate(Bylaw.Ecto.Query.Check.operation(), Bylaw.Ecto.Query.Check.query(), opts()) ::
          Bylaw.Ecto.Query.Check.result()
  def validate(operation, query, opts) when is_list(opts) do
    check_opts = CheckOptions.normalize!(opts, [:keys, :match, :validate])

    if CheckOptions.enabled?(check_opts) do
      keys = CheckOptions.fetch_non_empty_atoms!(check_opts, :keys)
      match = CheckOptions.match!(check_opts)

      case issues(operation, query, keys, match) do
        [] -> :ok
        issues -> {:error, issues}
      end
    else
      :ok
    end
  end

  def validate(_operation, _query, opts) do
    raise ArgumentError, "expected opts to be a keyword list, got: #{inspect(opts)}"
  end

  defp issues(operation, query, keys, match) when is_map(query) do
    aliases = Introspection.aliases(query)

    query
    |> Map.get(:joins, [])
    |> Enum.with_index()
    |> Enum.flat_map(fn {join, join_index} ->
      binding_index = join_index + 1

      with {:ok, schema} <- Introspection.explicit_join_schema(join),
           [_first_key | _remaining_keys] = applicable_keys <- applicable_keys(schema, keys),
           found_keys <- join_keys(join, binding_index, aliases),
           missing_keys <- missing_keys(applicable_keys, found_keys, match),
           false <- Enum.empty?(missing_keys) do
        [
          issue(
            operation,
            join_index,
            binding_index,
            schema,
            applicable_keys,
            found_keys,
            missing_keys,
            match
          )
        ]
      else
        _other -> []
      end
    end)
  end

  defp issues(_operation, _query, _keys, _match), do: []

  defp applicable_keys(schema, keys) do
    schema_fields = Introspection.schema_fields(schema)
    Enum.filter(keys, &MapSet.member?(schema_fields, &1))
  end

  @spec join_keys(term(), pos_integer(), map()) :: field_set()
  defp join_keys(%{on: %{expr: expr}}, binding_index, aliases) do
    expr
    |> keys_in_join_expr(binding_index, aliases)
    |> Enum.uniq()
  end

  defp join_keys(_join, _binding_index, _aliases), do: []

  defp keys_in_join_expr({:and, _meta, [left, right]}, binding_index, aliases) do
    keys_in_join_expr(left, binding_index, aliases) ++
      keys_in_join_expr(right, binding_index, aliases)
  end

  defp keys_in_join_expr({:==, _meta, [left, right]}, binding_index, aliases) do
    with {:ok, {left_index, left_field}} <- Introspection.field(left, aliases),
         {:ok, {right_index, right_field}} <- Introspection.field(right, aliases),
         true <- left_field == right_field do
      cond do
        left_index == binding_index and right_index < binding_index ->
          [left_field]

        right_index == binding_index and left_index < binding_index ->
          [right_field]

        true ->
          []
      end
    else
      _other -> []
    end
  end

  defp keys_in_join_expr(_expr, _binding_index, _aliases), do: []

  @spec missing_keys(list(atom()), field_set(), match()) :: list(atom())
  defp missing_keys(keys, found_keys, :any) do
    if Enum.any?(keys, &Enum.member?(found_keys, &1)), do: [], else: keys
  end

  defp missing_keys(keys, found_keys, :all) do
    Enum.reject(keys, &Enum.member?(found_keys, &1))
  end

  @spec issue(
          Bylaw.Ecto.Query.Check.operation(),
          non_neg_integer(),
          pos_integer(),
          module(),
          list(atom()),
          field_set(),
          list(atom()),
          match()
        ) :: Issue.t()
  defp issue(operation, join_index, binding_index, schema, keys, found_keys, missing_keys, match) do
    %Issue{
      check: __MODULE__,
      message: message(schema, keys, missing_keys, match),
      meta: %{
        operation: operation,
        join_index: join_index,
        binding_index: binding_index,
        join_schema: schema,
        keys: keys,
        match: match,
        missing_keys: missing_keys,
        found_join_keys: Enum.sort(found_keys)
      }
    }
  end

  defp message(schema, keys, _missing_keys, :any) do
    "expected explicit join to #{inspect(schema)} to match at least one mandatory key with an earlier binding: #{format_keys(keys)}"
  end

  defp message(schema, _keys, missing_keys, :all) do
    "expected explicit join to #{inspect(schema)} to match all mandatory keys with an earlier binding; missing: #{format_keys(missing_keys)}"
  end

  defp format_keys(keys), do: Enum.map_join(keys, ", ", &inspect/1)
end