refactor(settings): unify JSONB single-field update between member-field changes

This commit is contained in:
Moritz 2026-06-16 15:13:27 +02:00
parent 8ae8d92df0
commit 1b2b27368c
3 changed files with 124 additions and 113 deletions

View file

@ -0,0 +1,98 @@
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