Skip to content

Commit bf76a6b

Browse files
committed
improvement: add dedicated calculation argument transformer
1 parent 41a5f51 commit bf76a6b

7 files changed

Lines changed: 270 additions & 7 deletions

File tree

.formatter.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ spark_locals_without_parens = [
1111
base_route: 1,
1212
base_route: 2,
1313
base_route: 3,
14+
calculation_argument_names: 1,
1415
default_fields: 1,
1516
delete: 1,
1617
delete: 2,

documentation/dsls/DSL-AshJsonApi.Resource.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ end
7171
| [`derive_filter?`](#json_api-derive_filter?){: #json_api-derive_filter? } | `boolean` | `true` | Whether or not to derive a filter parameter based on the sortable fields of the resource |
7272
| [`field_names`](#json_api-field_names){: #json_api-field_names } | `:camelize \| :dasherize \| keyword \| (any -> any)` | | Renames fields (attributes, relationships, calculations, and aggregates) in the JSON:API output and input. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a keyword list of `[ash_name: :json_api_name]` mappings, or a 1-arity function that receives an atom field name and returns the desired JSON:API name (atom or string). ```elixir field_names :camelize # first_name → firstName field_names :dasherize # first_name → first-name ``` Or with a keyword list: ```elixir field_names [ first_name: :firstName, last_name: :lastName ] ``` Or with a function for custom logic: ```elixir field_names fn name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` Names are applied consistently across serialization, request parsing, sort/filter parameters, field selection, error source pointers, relationship keys, and schema generation. |
7373
| [`argument_names`](#json_api-argument_names){: #json_api-argument_names } | `:camelize \| :dasherize \| keyword \| (any, any -> any)` | | Renames action arguments in the JSON:API request body and schema. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a nested keyword list of `[action_name: [ash_name: :json_api_name]]` mappings, or a 2-arity function that receives `(action_name, argument_name)` atoms and returns the desired JSON:API name (atom or string). ```elixir argument_names :camelize # publish_at → publishAt argument_names :dasherize # publish_at → publish-at ``` Or with a keyword list: ```elixir argument_names [ create: [my_arg: :myArg], update: [my_arg: :myArg] ] ``` Or with a function: ```elixir argument_names fn _action, name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` |
74+
| [`calculation_argument_names`](#json_api-calculation_argument_names){: #json_api-calculation_argument_names } | `:camelize \| :dasherize \| keyword \| (any, any -> any)` | | Renames calculation arguments in the JSON:API request and schema. Works the same way as `argument_names` but applies to calculation arguments instead of action arguments. The 2-arity function receives `(calculation_name, argument_name)`. Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion, a nested keyword list of `[calc_name: [ash_name: :json_api_name]]` mappings, or a 2-arity function that receives `(calculation_name, argument_name)` atoms and returns the desired JSON:API name (atom or string). ```elixir calculation_argument_names :camelize # publish_at → publishAt calculation_argument_names :dasherize # publish_at → publish-at ``` Or with a keyword list: ```elixir calculation_argument_names [ full_name: [separator: :sep] ] ``` Or with a function: ```elixir calculation_argument_names fn _calc, name -> camelized = name \|> to_string() \|> Macro.camelize() {first, rest} = String.split_at(camelized, 1) String.downcase(first) <> rest end ``` |
7475

7576

7677
### json_api.routes

lib/ash_json_api/json_schema/open_api.ex

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2847,7 +2847,15 @@ if Code.ensure_loaded?(OpenApiSpex) do
28472847
{inputs, acc} =
28482848
Enum.reduce(calculation.arguments, {[], acc}, fn argument, {inputs, acc} ->
28492849
{schema, acc} = resource_write_attribute_type(argument, resource, :create, acc)
2850-
{[{argument.name, schema} | inputs], acc}
2850+
2851+
json_key =
2852+
AshJsonApi.Resource.Info.calculation_argument_to_json_key(
2853+
resource,
2854+
calculation.name,
2855+
argument.name
2856+
)
2857+
2858+
{[{json_key, schema} | inputs], acc}
28512859
end)
28522860

28532861
inputs = Enum.reverse(inputs)
@@ -2857,7 +2865,13 @@ if Code.ensure_loaded?(OpenApiSpex) do
28572865
if argument.allow_nil? do
28582866
[]
28592867
else
2860-
[argument.name]
2868+
[
2869+
AshJsonApi.Resource.Info.calculation_argument_to_json_key(
2870+
resource,
2871+
calculation.name,
2872+
argument.name
2873+
)
2874+
]
28612875
end
28622876
end)
28632877

lib/ash_json_api/request.ex

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,15 @@ defmodule AshJsonApi.Request do
688688
|> Enum.find(&(AshJsonApi.Resource.Info.type(&1) == type))
689689

690690
Enum.reduce(field_inputs, request, fn {calculation_name, arguments}, request ->
691-
case Ash.Resource.Info.public_calculation(resource, calculation_name) do
691+
resolved_name =
692+
if resource do
693+
AshJsonApi.Resource.Info.json_key_to_field(resource, calculation_name) ||
694+
calculation_name
695+
else
696+
calculation_name
697+
end
698+
699+
case Ash.Resource.Info.public_calculation(resource, resolved_name) do
692700
nil ->
693701
add_error(
694702
request,
@@ -698,12 +706,12 @@ defmodule AshJsonApi.Request do
698706

699707
calculation ->
700708
Enum.reduce(arguments, request, fn {arg_name, arg_value}, request ->
701-
arg_names = AshJsonApi.Resource.Info.argument_names(resource)
709+
calc_arg_names = AshJsonApi.Resource.Info.calculation_argument_names(resource)
702710

703711
calculation_arg =
704712
Enum.find(calculation.arguments, fn argument ->
705713
AshJsonApi.Resource.Info.apply_argument_name_mapping(
706-
arg_names,
714+
calc_arg_names,
707715
calculation.name,
708716
argument.name
709717
) == arg_name

lib/ash_json_api/resource/info.ex

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,18 @@ defmodule AshJsonApi.Resource.Info do
104104
end
105105
end
106106

107+
@doc """
108+
Returns the `calculation_argument_names` config for the resource: a keyword list, a 2-arity function,
109+
or one of the atoms `:camelize` / `:dasherize` (resolved to the corresponding function).
110+
"""
111+
def calculation_argument_names(resource) do
112+
case Extension.get_opt(resource, [:json_api], :calculation_argument_names, [], true) do
113+
:camelize -> fn _calc, name -> camelize(name) end
114+
:dasherize -> fn _calc, name -> dasherize(name) end
115+
other -> other
116+
end
117+
end
118+
107119
@doc """
108120
Converts an Ash atom field name (attribute, calculation, aggregate) to its JSON:API
109121
string key, applying any `field_names` mapping configured on the resource.
@@ -159,12 +171,21 @@ defmodule AshJsonApi.Resource.Info do
159171
end
160172
end
161173

174+
@doc """
175+
Converts an Ash calculation argument atom name to its JSON:API string key, applying any
176+
`calculation_argument_names` mapping configured on the resource for the given calculation.
177+
"""
178+
def calculation_argument_to_json_key(resource, calc_name, arg_name) do
179+
names = calculation_argument_names(resource)
180+
apply_argument_name_mapping(names, calc_name, arg_name)
181+
end
182+
162183
@doc """
163184
Converts a JSON:API string key to an Ash argument atom name for the given calculation,
164-
applying the reverse of any `argument_names` mapping. Returns `nil` if not found.
185+
applying the reverse of any `calculation_argument_names` mapping. Returns `nil` if not found.
165186
"""
166187
def json_key_to_calculation_argument(resource, calc_name, json_key) do
167-
names = argument_names(resource)
188+
names = calculation_argument_names(resource)
168189

169190
case Ash.Resource.Info.public_calculation(resource, calc_name) do
170191
%{arguments: args} ->

lib/ash_json_api/resource/resource.ex

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,44 @@ defmodule AshJsonApi.Resource do
631631
end
632632
```
633633
"""
634+
],
635+
calculation_argument_names: [
636+
type: {:or, [{:literal, :camelize}, {:literal, :dasherize}, :keyword_list, {:fun, 2}]},
637+
doc: """
638+
Renames calculation arguments in the JSON:API request and schema.
639+
640+
Works the same way as `argument_names` but applies to calculation arguments
641+
instead of action arguments. The 2-arity function receives
642+
`(calculation_name, argument_name)`.
643+
644+
Can be one of the atoms `:camelize` or `:dasherize` for automatic conversion,
645+
a nested keyword list of `[calc_name: [ash_name: :json_api_name]]` mappings,
646+
or a 2-arity function that receives `(calculation_name, argument_name)` atoms and
647+
returns the desired JSON:API name (atom or string).
648+
649+
```elixir
650+
calculation_argument_names :camelize # publish_at → publishAt
651+
calculation_argument_names :dasherize # publish_at → publish-at
652+
```
653+
654+
Or with a keyword list:
655+
656+
```elixir
657+
calculation_argument_names [
658+
full_name: [separator: :sep]
659+
]
660+
```
661+
662+
Or with a function:
663+
664+
```elixir
665+
calculation_argument_names fn _calc, name ->
666+
camelized = name |> to_string() |> Macro.camelize()
667+
{first, rest} = String.split_at(camelized, 1)
668+
String.downcase(first) <> rest
669+
end
670+
```
671+
"""
634672
]
635673
]
636674
}

test/acceptance/field_names_test.exs

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,95 @@ defmodule Test.Acceptance.FieldNamesTest do
271271
end
272272
end
273273

274+
# ─── Resource with keyword-list calculation_argument_names ───────────────────
275+
276+
defmodule CalcPost do
277+
use Ash.Resource,
278+
domain: Test.Acceptance.FieldNamesTest.Domain,
279+
data_layer: Ash.DataLayer.Ets,
280+
extensions: [AshJsonApi.Resource]
281+
282+
ets do
283+
private?(true)
284+
end
285+
286+
json_api do
287+
type "calc_post"
288+
289+
calculation_argument_names(full_name: [use_separator: :sep])
290+
291+
routes do
292+
base "/calc_posts"
293+
get :read
294+
index :read
295+
post :create
296+
end
297+
end
298+
299+
attributes do
300+
uuid_primary_key(:id)
301+
attribute(:first_name, :string, allow_nil?: false, public?: true)
302+
attribute(:last_name, :string, allow_nil?: false, public?: true)
303+
end
304+
305+
actions do
306+
default_accept([:first_name, :last_name])
307+
defaults([:read, :create, :destroy])
308+
end
309+
310+
calculations do
311+
calculate :full_name, :string, expr(first_name <> ^arg(:use_separator) <> last_name) do
312+
argument(:use_separator, :string, allow_nil?: false, default: " ")
313+
public?(true)
314+
end
315+
end
316+
end
317+
318+
# ─── Resource with :camelize calculation_argument_names ─────────────────────
319+
320+
defmodule CamelCalcPost do
321+
use Ash.Resource,
322+
domain: Test.Acceptance.FieldNamesTest.Domain,
323+
data_layer: Ash.DataLayer.Ets,
324+
extensions: [AshJsonApi.Resource]
325+
326+
ets do
327+
private?(true)
328+
end
329+
330+
json_api do
331+
type "camel_calc_post"
332+
333+
field_names :camelize
334+
calculation_argument_names :camelize
335+
336+
routes do
337+
base "/camel_calc_posts"
338+
get :read
339+
index :read
340+
post :create
341+
end
342+
end
343+
344+
attributes do
345+
uuid_primary_key(:id)
346+
attribute(:first_name, :string, allow_nil?: false, public?: true)
347+
attribute(:last_name, :string, allow_nil?: false, public?: true)
348+
end
349+
350+
actions do
351+
default_accept([:first_name, :last_name])
352+
defaults([:read, :create, :destroy])
353+
end
354+
355+
calculations do
356+
calculate :full_name, :string, expr(first_name <> ^arg(:join_string) <> last_name) do
357+
argument(:join_string, :string, allow_nil?: false, default: " ")
358+
public?(true)
359+
end
360+
end
361+
end
362+
274363
# ─── Resource without any name remapping (baseline) ──────────────────────────
275364

276365
defmodule Widget do
@@ -326,6 +415,8 @@ defmodule Test.Acceptance.FieldNamesTest do
326415
resource Tag
327416
resource CamelPost
328417
resource DashPost
418+
resource CalcPost
419+
resource CamelCalcPost
329420
resource Widget
330421
end
331422
end
@@ -1000,4 +1091,93 @@ defmodule Test.Acceptance.FieldNamesTest do
10001091
assert List.first(data)["attributes"]["post-title"] == "AAA"
10011092
end
10021093
end
1094+
1095+
# ─── calculation_argument_names: keyword list ──────────────────────────────
1096+
1097+
describe "calculation_argument_names (keyword list)" do
1098+
test "calculation argument sent under renamed key via field_inputs" do
1099+
post =
1100+
CalcPost
1101+
|> Ash.Changeset.for_create(:create, %{first_name: "Ada", last_name: "Lovelace"})
1102+
|> Ash.create!()
1103+
1104+
response =
1105+
Domain
1106+
|> get(
1107+
"/calc_posts/#{post.id}?fields[calc_post]=first_name,last_name,full_name&field_inputs[calc_post][full_name][sep]=-",
1108+
status: 200
1109+
)
1110+
1111+
attrs = response.resp_body["data"]["attributes"]
1112+
assert attrs["full_name"] == "Ada-Lovelace"
1113+
end
1114+
1115+
test "info helpers use calculation_argument_names not argument_names" do
1116+
assert AshJsonApi.Resource.Info.calculation_argument_to_json_key(
1117+
CalcPost,
1118+
:full_name,
1119+
:use_separator
1120+
) == "sep"
1121+
1122+
# argument_to_json_key should NOT rename calc args (it uses argument_names, not calculation_argument_names)
1123+
assert AshJsonApi.Resource.Info.argument_to_json_key(
1124+
CalcPost,
1125+
:full_name,
1126+
:use_separator
1127+
) == "use_separator"
1128+
end
1129+
1130+
test "json_key_to_calculation_argument reverses the mapping" do
1131+
assert AshJsonApi.Resource.Info.json_key_to_calculation_argument(
1132+
CalcPost,
1133+
:full_name,
1134+
"sep"
1135+
) == :use_separator
1136+
end
1137+
1138+
test "json_key_to_calculation_argument returns nil for unknown key" do
1139+
assert AshJsonApi.Resource.Info.json_key_to_calculation_argument(
1140+
CalcPost,
1141+
:full_name,
1142+
"nonexistent"
1143+
) == nil
1144+
end
1145+
end
1146+
1147+
# ─── calculation_argument_names: :camelize ─────────────────────────────────
1148+
1149+
describe "calculation_argument_names (:camelize)" do
1150+
test "calculation argument sent under camelCase key via field_inputs" do
1151+
post =
1152+
CamelCalcPost
1153+
|> Ash.Changeset.for_create(:create, %{first_name: "Grace", last_name: "Hopper"})
1154+
|> Ash.create!()
1155+
1156+
response =
1157+
Domain
1158+
|> get(
1159+
"/camel_calc_posts/#{post.id}?fields[camel_calc_post]=firstName,lastName,fullName&field_inputs[camel_calc_post][fullName][joinString]=-",
1160+
status: 200
1161+
)
1162+
1163+
attrs = response.resp_body["data"]["attributes"]
1164+
assert attrs["fullName"] == "Grace-Hopper"
1165+
end
1166+
1167+
test "info helpers apply camelize to calculation arguments" do
1168+
assert AshJsonApi.Resource.Info.calculation_argument_to_json_key(
1169+
CamelCalcPost,
1170+
:full_name,
1171+
:join_string
1172+
) == "joinString"
1173+
end
1174+
1175+
test "json_key_to_calculation_argument reverses camelize mapping" do
1176+
assert AshJsonApi.Resource.Info.json_key_to_calculation_argument(
1177+
CamelCalcPost,
1178+
:full_name,
1179+
"joinString"
1180+
) == :join_string
1181+
end
1182+
end
10031183
end

0 commit comments

Comments
 (0)