Skip to content

Commit 18b6584

Browse files
authored
Merge pull request #71 from mike1o1/module-transform-formatting-change
refactor(field_formatter): use Spark transformer for field-name cache
2 parents 95eb2e2 + b2d30bf commit 18b6584

5 files changed

Lines changed: 298 additions & 36 deletions

File tree

lib/ash_typescript/field_formatter.ex

Lines changed: 10 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -35,25 +35,16 @@ defmodule AshTypescript.FieldFormatter do
3535
"""
3636
def format_field_for_client(field, resource_or_type_module \\ nil, formatter)
3737

38-
# Per-process memoization: when the inputs are fully static (atom field,
39-
# module-or-nil resource, built-in formatter) the result is a pure function of
40-
# those three values. Caching on the process dict captures the per-request hot
41-
# loop in OutputFormatter where the same (field, resource, formatter) triple
42-
# is looked up once per record (~1500× for a full sync). No global state, no
43-
# invalidation — the cache dies with the process.
4438
def format_field_for_client(field, resource, formatter)
45-
when is_atom(field) and is_atom(resource) and
39+
when is_atom(field) and is_atom(resource) and not is_nil(resource) and
4640
formatter in [:camel_case, :snake_case, :pascal_case] do
47-
cache_key = {:ash_typescript_ffc, field, resource, formatter}
48-
49-
case Process.get(cache_key) do
50-
nil ->
51-
result = compute_field_for_client(field, resource, formatter)
52-
Process.put(cache_key, result)
53-
result
54-
55-
cached ->
56-
cached
41+
if Introspection.has_typescript_field_names?(resource) do
42+
compute_field_for_client(field, resource, formatter)
43+
else
44+
case AshTypescript.Resource.Info.get_formatted_field(resource, field, formatter) do
45+
formatted when is_binary(formatted) -> formatted
46+
nil -> compute_field_for_client(field, resource, formatter)
47+
end
5748
end
5849
end
5950

@@ -234,27 +225,10 @@ defmodule AshTypescript.FieldFormatter do
234225
iex> AshTypescript.FieldFormatter.format_field_name("user_name", :pascal_case)
235226
"UserName"
236227
"""
237-
# Per-process memoization for atom inputs with built-in formatters. See
238-
# format_field_for_client/3 above for the rationale; the same logic applies
239-
# here, just keyed on (field_atom, formatter).
240-
def format_field_name(field_name, formatter)
241-
when is_atom(field_name) and formatter in [:camel_case, :snake_case, :pascal_case] do
242-
cache_key = {:ash_typescript_ffn, field_name, formatter}
243-
244-
case Process.get(cache_key) do
245-
nil ->
246-
result = compute_field_name(field_name, formatter)
247-
Process.put(cache_key, result)
248-
result
249-
250-
cached ->
251-
cached
252-
end
253-
end
254-
255228
def format_field_name(field_name, formatter), do: compute_field_name(field_name, formatter)
256229

257-
defp compute_field_name(field_name, formatter) do
230+
@doc false
231+
def compute_field_name(field_name, formatter) do
258232
string_field = to_string(field_name)
259233

260234
case formatter do

lib/ash_typescript/resource.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ defmodule AshTypescript.Resource do
3939

4040
use Spark.Dsl.Extension,
4141
sections: [@typescript],
42+
transformers: [
43+
AshTypescript.Resource.Transformers.PersistFormattedFields
44+
],
4245
verifiers: [
4346
AshTypescript.Resource.Verifiers.VerifyUniqueTypeNames,
4447
AshTypescript.Resource.Verifiers.VerifyFieldNames,

lib/ash_typescript/resource/info.ex

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,32 @@ defmodule AshTypescript.Resource.Info do
3838
Keyword.get(mapped_names, field_name)
3939
end
4040

41+
@doc """
42+
Gets the pre-computed formatted client name for a field under a built-in formatter.
43+
44+
Backed by `Spark.Dsl.Transformer.persist/3` populated at compile time by
45+
`AshTypescript.Resource.Transformers.PersistFormattedFields`. Returns the
46+
formatted string, or `nil` if the resource is not an `AshTypescript.Resource`,
47+
the field is not a public attribute/relationship/calculation/aggregate, or the
48+
formatter is not one of the built-in atoms (`:camel_case`, `:snake_case`,
49+
`:pascal_case`).
50+
51+
## Examples
52+
53+
iex> AshTypescript.Resource.Info.get_formatted_field(MyApp.User, :first_name, :camel_case)
54+
"firstName"
55+
56+
iex> AshTypescript.Resource.Info.get_formatted_field(MyApp.User, :is_active?, :camel_case)
57+
"isActive"
58+
"""
59+
def get_formatted_field(resource, field, formatter)
60+
when is_atom(resource) and is_atom(field) do
61+
Spark.Dsl.Extension.get_persisted(
62+
resource,
63+
{:typescript_formatted_fields, field, formatter}
64+
)
65+
end
66+
4167
@doc """
4268
Gets the original Elixir field name for a TypeScript client field name.
4369
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs/contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule AshTypescript.Resource.Transformers.PersistFormattedFields do
6+
@moduledoc false
7+
8+
use Spark.Dsl.Transformer
9+
10+
@builtin_formatters [:camel_case, :snake_case, :pascal_case]
11+
12+
def after?(_), do: true
13+
14+
def transform(dsl_state) do
15+
fields = collect_public_field_atoms(dsl_state)
16+
overrides = Spark.Dsl.Transformer.get_option(dsl_state, [:typescript], :field_names, [])
17+
18+
pairs = for field <- fields, formatter <- @builtin_formatters, do: {field, formatter}
19+
20+
persisted =
21+
Enum.reduce(pairs, dsl_state, fn {field, formatter}, acc ->
22+
Spark.Dsl.Transformer.persist(
23+
acc,
24+
{:typescript_formatted_fields, field, formatter},
25+
formatted_name(field, formatter, overrides)
26+
)
27+
end)
28+
29+
{:ok, persisted}
30+
end
31+
32+
defp formatted_name(field, formatter, overrides) do
33+
case Keyword.fetch(overrides, field) do
34+
{:ok, override} when is_binary(override) -> override
35+
_ -> AshTypescript.FieldFormatter.compute_field_name(field, formatter)
36+
end
37+
end
38+
39+
defp collect_public_field_atoms(dsl_state) do
40+
[
41+
Ash.Resource.Info.public_attributes(dsl_state),
42+
Ash.Resource.Info.public_relationships(dsl_state),
43+
Ash.Resource.Info.public_calculations(dsl_state),
44+
Ash.Resource.Info.public_aggregates(dsl_state)
45+
]
46+
|> Enum.concat()
47+
|> Enum.map(& &1.name)
48+
|> Enum.uniq()
49+
end
50+
end
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
# SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs/contributors>
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
defmodule AshTypescript.Resource.Transformers.PersistFormattedFieldsTest do
6+
use ExUnit.Case, async: true
7+
8+
alias AshTypescript.FieldFormatter
9+
alias AshTypescript.Resource.Info
10+
11+
defmodule PlainResource do
12+
use Ash.Resource,
13+
domain: nil,
14+
data_layer: Ash.DataLayer.Ets,
15+
extensions: [AshTypescript.Resource]
16+
17+
typescript do
18+
type_name "PlainResource"
19+
20+
field_names address_line_1: "addressLine1",
21+
is_active?: "isActive"
22+
end
23+
24+
attributes do
25+
uuid_primary_key :id
26+
attribute :first_name, :string, public?: true
27+
attribute :address_line_1, :string, public?: true
28+
attribute :is_active?, :boolean, public?: true
29+
attribute :is_super_admin, :boolean, public?: true
30+
end
31+
end
32+
33+
defmodule ResourceWithCallback do
34+
use Ash.Resource,
35+
domain: nil,
36+
data_layer: Ash.DataLayer.Ets,
37+
extensions: [AshTypescript.Resource]
38+
39+
typescript do
40+
type_name "ResourceWithCallback"
41+
field_names is_active?: "isActiveFromDsl"
42+
end
43+
44+
attributes do
45+
uuid_primary_key :id
46+
attribute :is_active?, :boolean, public?: true
47+
attribute :first_name, :string, public?: true
48+
end
49+
50+
# Module-level callback — takes priority over `field_names` DSL per the
51+
# documented precedence in `compute_field_for_client/3`.
52+
def typescript_field_names do
53+
[is_active?: "isActiveFromCallback"]
54+
end
55+
end
56+
57+
defmodule NonTypescriptResource do
58+
use Ash.Resource,
59+
domain: nil,
60+
data_layer: Ash.DataLayer.Ets
61+
62+
attributes do
63+
uuid_primary_key :id
64+
attribute :name, :string, public?: true
65+
end
66+
end
67+
68+
defmodule LinkedResource do
69+
use Ash.Resource,
70+
domain: nil,
71+
data_layer: Ash.DataLayer.Ets
72+
73+
attributes do
74+
uuid_primary_key :id
75+
attribute :resource_with_all_kinds_id, :uuid, public?: true
76+
end
77+
end
78+
79+
defmodule ResourceWithAllKinds do
80+
use Ash.Resource,
81+
domain: nil,
82+
data_layer: Ash.DataLayer.Ets,
83+
extensions: [AshTypescript.Resource]
84+
85+
typescript do
86+
type_name "ResourceWithAllKinds"
87+
end
88+
89+
attributes do
90+
uuid_primary_key :id
91+
attribute :first_name, :string, public?: true
92+
end
93+
94+
relationships do
95+
has_many :linked_resources, LinkedResource, public?: true
96+
end
97+
98+
calculations do
99+
calculate :display_name, :string, expr(first_name) do
100+
public? true
101+
end
102+
end
103+
104+
aggregates do
105+
count :linked_count, :linked_resources do
106+
public? true
107+
end
108+
end
109+
end
110+
111+
describe "Resource.Info.get_formatted_field/3" do
112+
test "returns formatted name for unmapped public attribute" do
113+
assert Info.get_formatted_field(PlainResource, :first_name, :camel_case) == "firstName"
114+
assert Info.get_formatted_field(PlainResource, :first_name, :snake_case) == "first_name"
115+
assert Info.get_formatted_field(PlainResource, :first_name, :pascal_case) == "FirstName"
116+
end
117+
118+
test "returns the field_names override regardless of formatter" do
119+
# Override wins for ALL builtin formatters — the DSL value is the literal
120+
# client name with no further formatting applied.
121+
assert Info.get_formatted_field(PlainResource, :address_line_1, :camel_case) ==
122+
"addressLine1"
123+
124+
assert Info.get_formatted_field(PlainResource, :address_line_1, :snake_case) ==
125+
"addressLine1"
126+
127+
assert Info.get_formatted_field(PlainResource, :address_line_1, :pascal_case) ==
128+
"addressLine1"
129+
end
130+
131+
test "applies formatter to fields without override" do
132+
assert Info.get_formatted_field(PlainResource, :is_super_admin, :camel_case) ==
133+
"isSuperAdmin"
134+
135+
assert Info.get_formatted_field(PlainResource, :is_super_admin, :pascal_case) ==
136+
"IsSuperAdmin"
137+
end
138+
139+
test "returns nil for missing field" do
140+
assert Info.get_formatted_field(PlainResource, :nonexistent_field, :camel_case) == nil
141+
end
142+
143+
test "returns nil for non-AshTypescript resource" do
144+
assert Info.get_formatted_field(NonTypescriptResource, :name, :camel_case) == nil
145+
end
146+
147+
test "returns nil for non-builtin formatter" do
148+
# Only camel/snake/pascal are pre-computed at compile time. MFA tuples
149+
# and any other formatter shape fall through to the runtime path.
150+
assert Info.get_formatted_field(PlainResource, :first_name, {SomeMod, :format}) == nil
151+
end
152+
153+
test "collects public attributes" do
154+
assert Info.get_formatted_field(ResourceWithAllKinds, :first_name, :camel_case) ==
155+
"firstName"
156+
end
157+
158+
test "collects public relationships" do
159+
assert Info.get_formatted_field(ResourceWithAllKinds, :linked_resources, :camel_case) ==
160+
"linkedResources"
161+
end
162+
163+
test "collects public calculations" do
164+
assert Info.get_formatted_field(ResourceWithAllKinds, :display_name, :camel_case) ==
165+
"displayName"
166+
end
167+
168+
test "collects public aggregates" do
169+
assert Info.get_formatted_field(ResourceWithAllKinds, :linked_count, :camel_case) ==
170+
"linkedCount"
171+
end
172+
end
173+
174+
describe "format_field_for_client/3 precedence" do
175+
test "uses persisted state for resource without callback (fast path)" do
176+
assert FieldFormatter.format_field_for_client(:first_name, PlainResource, :camel_case) ==
177+
"firstName"
178+
179+
assert FieldFormatter.format_field_for_client(:address_line_1, PlainResource, :camel_case) ==
180+
"addressLine1"
181+
end
182+
183+
test "callback takes priority over persisted DSL state" do
184+
# ResourceWithCallback has BOTH field_names DSL AND a typescript_field_names/0
185+
# callback. The callback must win.
186+
assert FieldFormatter.format_field_for_client(
187+
:is_active?,
188+
ResourceWithCallback,
189+
:camel_case
190+
) == "isActiveFromCallback"
191+
end
192+
193+
test "callback path falls through to formatter for fields not in callback map" do
194+
# `:first_name` is NOT in the callback's typescript_field_names list, so
195+
# the callback path falls through to format_field_name/2 — NOT to the DSL.
196+
# This matches the documented behavior in compute_field_for_client/3.
197+
assert FieldFormatter.format_field_for_client(
198+
:first_name,
199+
ResourceWithCallback,
200+
:camel_case
201+
) == "firstName"
202+
end
203+
204+
test "non-typescript resource falls through to plain formatter" do
205+
assert FieldFormatter.format_field_for_client(:name, NonTypescriptResource, :camel_case) ==
206+
"name"
207+
end
208+
end
209+
end

0 commit comments

Comments
 (0)