Skip to content

Commit 8e446ab

Browse files
feat: support building error payloads using immutable functions
1 parent eb3f916 commit 8e446ab

File tree

1 file changed

+101
-52
lines changed

1 file changed

+101
-52
lines changed

lib/extensions/immutable_raise_error.ex

Lines changed: 101 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -30,25 +30,37 @@ defmodule AshPostgres.Extensions.ImmutableRaiseError do
3030
```
3131
"""
3232

33-
use AshPostgres.CustomExtension, name: "immutable_raise_error", latest_version: 1
33+
use AshPostgres.CustomExtension, name: "immutable_raise_error", latest_version: 2
3434

3535
require Ecto.Query
3636

3737
@impl true
3838
def install(0) do
39-
ash_raise_error_immutable()
39+
"""
40+
#{ash_raise_error_immutable()}
41+
42+
#{ash_to_jsonb_immutable()}
43+
"""
44+
end
45+
46+
def install(1) do
47+
ash_to_jsonb_immutable()
4048
end
4149

4250
@impl true
51+
def uninstall(2) do
52+
"execute(\"DROP FUNCTION IF EXISTS ash_to_jsonb_immutable(anyelement)\")"
53+
end
54+
4355
def uninstall(_version) do
44-
"execute(\"DROP FUNCTION IF EXISTS ash_raise_error_immutable(jsonb, ANYCOMPATIBLE), ash_raise_error_immutable(jsonb, ANYELEMENT, ANYCOMPATIBLE)\")"
56+
"execute(\"DROP FUNCTION IF EXISTS ash_to_jsonb_immutable(anyelement), ash_raise_error_immutable(jsonb, anycompatible), ash_raise_error_immutable(jsonb, anyelement, anycompatible)\")"
4557
end
4658

4759
defp ash_raise_error_immutable do
4860
"""
4961
execute(\"\"\"
50-
CREATE OR REPLACE FUNCTION ash_raise_error_immutable(json_data jsonb, token ANYCOMPATIBLE)
51-
RETURNS BOOLEAN AS $$
62+
CREATE OR REPLACE FUNCTION ash_raise_error_immutable(json_data jsonb, token anycompatible)
63+
RETURNS boolean AS $$
5264
BEGIN
5365
-- Raise an error with the provided JSON data.
5466
-- The JSON object is converted to text for inclusion in the error message.
@@ -62,8 +74,8 @@ defmodule AshPostgres.Extensions.ImmutableRaiseError do
6274
\"\"\")
6375
6476
execute(\"\"\"
65-
CREATE OR REPLACE FUNCTION ash_raise_error_immutable(json_data jsonb, type_signal ANYELEMENT, token ANYCOMPATIBLE)
66-
RETURNS ANYELEMENT AS $$
77+
CREATE OR REPLACE FUNCTION ash_raise_error_immutable(json_data jsonb, type_signal anyelement, token anycompatible)
78+
RETURNS anyelement AS $$
6779
BEGIN
6880
-- Raise an error with the provided JSON data.
6981
-- The JSON object is converted to text for inclusion in the error message.
@@ -78,60 +90,48 @@ defmodule AshPostgres.Extensions.ImmutableRaiseError do
7890
"""
7991
end
8092

93+
# Wraps to_jsonb and pins session GUCs that affect JSON. This makes the function’s result
94+
# deterministic, so it is safe to mark IMMUTABLE.
95+
defp ash_to_jsonb_immutable do
96+
"""
97+
execute(\"\"\"
98+
CREATE OR REPLACE FUNCTION ash_to_jsonb_immutable(value anyelement)
99+
RETURNS jsonb
100+
LANGUAGE plpgsql
101+
IMMUTABLE
102+
SET search_path TO 'pg_catalog'
103+
SET \"TimeZone\" TO 'UTC'
104+
SET \"DateStyle\" TO 'ISO, YMD'
105+
SET \"IntervalStyle\" TO 'iso_8601'
106+
SET extra_float_digits TO '0'
107+
SET bytea_output TO 'hex'
108+
AS $function$
109+
BEGIN
110+
RETURN COALESCE(to_jsonb(value), 'null'::jsonb);
111+
END;
112+
$function$
113+
\"\"\")
114+
"""
115+
end
116+
81117
@doc false
82118
def immutable_error_expr(
83119
query,
84120
%Ash.Query.Function.Error{arguments: [exception, input]} = value,
85121
bindings,
86-
embedded?,
122+
_embedded?,
87123
acc,
88124
type
89125
) do
126+
if !(Keyword.keyword?(input) or is_map(input)) do
127+
raise "Input expression to `error` must be a map or keyword list"
128+
end
129+
90130
acc = %{acc | has_error?: true}
91131

92-
{encoded, acc} =
132+
{error_payload, acc} =
93133
if Ash.Expr.expr?(input) do
94-
frag_parts =
95-
Enum.flat_map(input, fn {key, value} ->
96-
if Ash.Expr.expr?(value) do
97-
[
98-
expr: to_string(key),
99-
raw: "::text, ",
100-
expr: value,
101-
raw: ", "
102-
]
103-
else
104-
[
105-
expr: to_string(key),
106-
raw: "::text, ",
107-
expr: value,
108-
raw: "::jsonb, "
109-
]
110-
end
111-
end)
112-
113-
frag_parts =
114-
List.update_at(frag_parts, -1, fn {:raw, text} ->
115-
{:raw, String.trim_trailing(text, ", ") <> "))"}
116-
end)
117-
118-
AshSql.Expr.dynamic_expr(
119-
query,
120-
%Ash.Query.Function.Fragment{
121-
embedded?: false,
122-
arguments:
123-
[
124-
raw: "jsonb_build_object('exception', ",
125-
expr: inspect(exception),
126-
raw: "::text, 'input', jsonb_build_object("
127-
] ++
128-
frag_parts
129-
},
130-
bindings,
131-
embedded?,
132-
nil,
133-
acc
134-
)
134+
expression_error_payload(exception, input, query, bindings, acc)
135135
else
136136
{Jason.encode!(%{exception: inspect(exception), input: Map.new(input)}), acc}
137137
end
@@ -163,22 +163,71 @@ defmodule AshPostgres.Extensions.ImmutableRaiseError do
163163
{nil, row_token} ->
164164
{:ok,
165165
Ecto.Query.dynamic(
166-
fragment("ash_raise_error_immutable(?::jsonb, ?)", ^encoded, ^row_token)
166+
fragment("ash_raise_error_immutable(?::jsonb, ?)", ^error_payload, ^row_token)
167167
), acc}
168168

169169
{dynamic_type, row_token} ->
170170
{:ok,
171171
Ecto.Query.dynamic(
172172
fragment(
173173
"ash_raise_error_immutable(?::jsonb, ?, ?)",
174-
^encoded,
174+
^error_payload,
175175
^dynamic_type,
176176
^row_token
177177
)
178178
), acc}
179179
end
180180
end
181181

182+
# Encodes an error payload as jsonb using only IMMUTABLE SQL functions.
183+
#
184+
# Strategy:
185+
# * Split the 'input' into Ash expressions and literal values
186+
# * Build the base json map with the exception name and literal input values
187+
# * For each expression value, use nested calls to `jsonb_set` (IMMUTABLE) to add the value to
188+
# 'input', converting each expression to jsonb using `ash_to_jsonb_immutable` (which pins
189+
# session GUCs for deterministic encoding)
190+
defp expression_error_payload(exception, input, query, bindings, acc) do
191+
{expr_inputs, literal_inputs} =
192+
Enum.split_with(input, fn {_key, value} -> Ash.Expr.expr?(value) end)
193+
194+
base_json = %{exception: inspect(exception), input: Map.new(literal_inputs)}
195+
196+
Enum.reduce(expr_inputs, {base_json, acc}, fn
197+
{key, expr_value}, {current_payload, acc} ->
198+
path_expr = %Ash.Query.Function.Type{
199+
arguments: [["input", to_string(key)], {:array, :string}, []]
200+
}
201+
202+
new_value_jsonb =
203+
%Ash.Query.Function.Fragment{
204+
arguments: [raw: "ash_to_jsonb_immutable(", expr: expr_value, raw: ")"]
205+
}
206+
207+
{%Ecto.Query.DynamicExpr{} = new_payload, acc} =
208+
AshSql.Expr.dynamic_expr(
209+
query,
210+
%Ash.Query.Function.Fragment{
211+
arguments: [
212+
raw: "jsonb_set(",
213+
expr: current_payload,
214+
raw: "::jsonb, ",
215+
expr: path_expr,
216+
raw: ", ",
217+
expr: new_value_jsonb,
218+
raw: "::jsonb, true)"
219+
]
220+
},
221+
bindings,
222+
false,
223+
nil,
224+
acc
225+
)
226+
227+
{new_payload, acc}
228+
end)
229+
end
230+
182231
# Returns a row-dependent token to prevent constant-folding for immutable functions.
183232
defp immutable_error_expr_token(query, bindings) do
184233
resource = query.__ash_bindings__.resource

0 commit comments

Comments
 (0)