defmodule Mv.Membership.Setting.Changes.JsonbResult do @moduledoc """ Shared normalization for the JSONB column values returned by the atomic single-member-field settings updates. PostgreSQL may return a JSONB column as an already-decoded map (atom or string keys) or as a raw JSON string depending on the driver path. This helper normalizes either form to a string-keyed map, returning `%{}` for unexpected or undecodable input. """ alias Ash.Error.Invalid alias Ecto.Adapters.SQL require Logger @doc """ Runs an atomic single-statement JSONB settings UPDATE inside an after_action and maps the RETURNING row back onto the settings struct. Shared by the single-member-field change modules, which differ only in the SQL statement, its parameters, the row-to-settings mapping, and the error labels. The three-branch result handling (row found / not found / SQL error) is identical and lives here. Options: - `:sql` - The UPDATE statement with a RETURNING clause (required). - `:params` - The full parameter list for the statement (required). - `:on_row` - 1-arity function mapping the RETURNING row (a list of column values) to the updated settings struct (required). - `:error_field` - Ash error field for the not-found / failure errors. - `:not_found_message` - Error message when no row matched. - `:error_message` - Error message when the SQL statement failed. - `:log_message` - Log prefix written on SQL failure. """ @spec run_update(keyword()) :: {:ok, map()} | {:error, Exception.t()} def run_update(opts) do sql = Keyword.fetch!(opts, :sql) params = Keyword.fetch!(opts, :params) on_row = Keyword.fetch!(opts, :on_row) error_field = Keyword.fetch!(opts, :error_field) not_found_message = Keyword.fetch!(opts, :not_found_message) error_message = Keyword.fetch!(opts, :error_message) log_message = Keyword.fetch!(opts, :log_message) case SQL.query(Mv.Repo, sql, params) do {:ok, %{rows: [row | _]}} -> {:ok, on_row.(row)} {:ok, %{rows: []}} -> {:error, Invalid.exception( field: error_field, message: not_found_message )} {:error, error} -> Logger.error("#{log_message}: #{inspect(error)}") {:error, Invalid.exception( field: error_field, message: error_message )} end end @doc """ Normalizes a JSONB column value to a string-keyed map. """ @spec normalize(map() | binary() | any()) :: map() def normalize(updated_jsonb) do case updated_jsonb do map when is_map(map) -> Enum.reduce(map, %{}, fn {k, v}, acc when is_atom(k) -> Map.put(acc, Atom.to_string(k), v) {k, v}, acc -> Map.put(acc, k, v) end) binary when is_binary(binary) -> case Jason.decode(binary) do {:ok, decoded} when is_map(decoded) -> decoded {:ok, _} -> %{} {:error, reason} -> Logger.warning("Failed to decode JSONB: #{inspect(reason)}") %{} end _ -> Logger.warning("Unexpected JSONB format: #{inspect(updated_jsonb)}") %{} end end end