Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 10 additions & 36 deletions lib/ash_typescript/field_formatter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -35,25 +35,16 @@ defmodule AshTypescript.FieldFormatter do
"""
def format_field_for_client(field, resource_or_type_module \\ nil, formatter)

# Per-process memoization: when the inputs are fully static (atom field,
# module-or-nil resource, built-in formatter) the result is a pure function of
# those three values. Caching on the process dict captures the per-request hot
# loop in OutputFormatter where the same (field, resource, formatter) triple
# is looked up once per record (~1500× for a full sync). No global state, no
# invalidation — the cache dies with the process.
def format_field_for_client(field, resource, formatter)
when is_atom(field) and is_atom(resource) and
when is_atom(field) and is_atom(resource) and not is_nil(resource) and
formatter in [:camel_case, :snake_case, :pascal_case] do
cache_key = {:ash_typescript_ffc, field, resource, formatter}

case Process.get(cache_key) do
nil ->
result = compute_field_for_client(field, resource, formatter)
Process.put(cache_key, result)
result

cached ->
cached
if Introspection.has_typescript_field_names?(resource) do
compute_field_for_client(field, resource, formatter)
else
case AshTypescript.Resource.Info.get_formatted_field(resource, field, formatter) do
formatted when is_binary(formatted) -> formatted
nil -> compute_field_for_client(field, resource, formatter)
end
end
end

Expand Down Expand Up @@ -234,27 +225,10 @@ defmodule AshTypescript.FieldFormatter do
iex> AshTypescript.FieldFormatter.format_field_name("user_name", :pascal_case)
"UserName"
"""
# Per-process memoization for atom inputs with built-in formatters. See
# format_field_for_client/3 above for the rationale; the same logic applies
# here, just keyed on (field_atom, formatter).
def format_field_name(field_name, formatter)
when is_atom(field_name) and formatter in [:camel_case, :snake_case, :pascal_case] do
cache_key = {:ash_typescript_ffn, field_name, formatter}

case Process.get(cache_key) do
nil ->
result = compute_field_name(field_name, formatter)
Process.put(cache_key, result)
result

cached ->
cached
end
end

def format_field_name(field_name, formatter), do: compute_field_name(field_name, formatter)

defp compute_field_name(field_name, formatter) do
@doc false
def compute_field_name(field_name, formatter) do
string_field = to_string(field_name)

case formatter do
Expand Down
3 changes: 3 additions & 0 deletions lib/ash_typescript/resource.ex
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ defmodule AshTypescript.Resource do

use Spark.Dsl.Extension,
sections: [@typescript],
transformers: [
AshTypescript.Resource.Transformers.PersistFormattedFields
],
verifiers: [
AshTypescript.Resource.Verifiers.VerifyUniqueTypeNames,
AshTypescript.Resource.Verifiers.VerifyFieldNames,
Expand Down
26 changes: 26 additions & 0 deletions lib/ash_typescript/resource/info.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,32 @@ defmodule AshTypescript.Resource.Info do
Keyword.get(mapped_names, field_name)
end

@doc """
Gets the pre-computed formatted client name for a field under a built-in formatter.

Backed by `Spark.Dsl.Transformer.persist/3` populated at compile time by
`AshTypescript.Resource.Transformers.PersistFormattedFields`. Returns the
formatted string, or `nil` if the resource is not an `AshTypescript.Resource`,
the field is not a public attribute/relationship/calculation/aggregate, or the
formatter is not one of the built-in atoms (`:camel_case`, `:snake_case`,
`:pascal_case`).

## Examples

iex> AshTypescript.Resource.Info.get_formatted_field(MyApp.User, :first_name, :camel_case)
"firstName"

iex> AshTypescript.Resource.Info.get_formatted_field(MyApp.User, :is_active?, :camel_case)
"isActive"
"""
def get_formatted_field(resource, field, formatter)
when is_atom(resource) and is_atom(field) do
Spark.Dsl.Extension.get_persisted(
resource,
{:typescript_formatted_fields, field, formatter}
)
end

@doc """
Gets the original Elixir field name for a TypeScript client field name.

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs/contributors>
#
# SPDX-License-Identifier: MIT

defmodule AshTypescript.Resource.Transformers.PersistFormattedFields do
@moduledoc false

use Spark.Dsl.Transformer

@builtin_formatters [:camel_case, :snake_case, :pascal_case]

def after?(_), do: true

def transform(dsl_state) do
fields = collect_public_field_atoms(dsl_state)
overrides = Spark.Dsl.Transformer.get_option(dsl_state, [:typescript], :field_names, [])

pairs = for field <- fields, formatter <- @builtin_formatters, do: {field, formatter}

persisted =
Enum.reduce(pairs, dsl_state, fn {field, formatter}, acc ->
Spark.Dsl.Transformer.persist(
acc,
{:typescript_formatted_fields, field, formatter},
formatted_name(field, formatter, overrides)
)
end)

{:ok, persisted}
end

defp formatted_name(field, formatter, overrides) do
case Keyword.fetch(overrides, field) do
{:ok, override} when is_binary(override) -> override
_ -> AshTypescript.FieldFormatter.compute_field_name(field, formatter)
end
end

defp collect_public_field_atoms(dsl_state) do
[
Ash.Resource.Info.public_attributes(dsl_state),
Ash.Resource.Info.public_relationships(dsl_state),
Ash.Resource.Info.public_calculations(dsl_state),
Ash.Resource.Info.public_aggregates(dsl_state)
]
|> Enum.concat()
|> Enum.map(& &1.name)
|> Enum.uniq()
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# SPDX-FileCopyrightText: 2025 ash_typescript contributors <https://github.com/ash-project/ash_typescript/graphs/contributors>
#
# SPDX-License-Identifier: MIT

defmodule AshTypescript.Resource.Transformers.PersistFormattedFieldsTest do
use ExUnit.Case, async: true

alias AshTypescript.FieldFormatter
alias AshTypescript.Resource.Info

defmodule PlainResource do
use Ash.Resource,
domain: nil,
data_layer: Ash.DataLayer.Ets,
extensions: [AshTypescript.Resource]

typescript do
type_name "PlainResource"

field_names address_line_1: "addressLine1",
is_active?: "isActive"
end

attributes do
uuid_primary_key :id
attribute :first_name, :string, public?: true
attribute :address_line_1, :string, public?: true
attribute :is_active?, :boolean, public?: true
attribute :is_super_admin, :boolean, public?: true
end
end

defmodule ResourceWithCallback do
use Ash.Resource,
domain: nil,
data_layer: Ash.DataLayer.Ets,
extensions: [AshTypescript.Resource]

typescript do
type_name "ResourceWithCallback"
field_names is_active?: "isActiveFromDsl"
end

attributes do
uuid_primary_key :id
attribute :is_active?, :boolean, public?: true
attribute :first_name, :string, public?: true
end

# Module-level callback — takes priority over `field_names` DSL per the
# documented precedence in `compute_field_for_client/3`.
def typescript_field_names do
[is_active?: "isActiveFromCallback"]
end
end

defmodule NonTypescriptResource do
use Ash.Resource,
domain: nil,
data_layer: Ash.DataLayer.Ets

attributes do
uuid_primary_key :id
attribute :name, :string, public?: true
end
end

defmodule LinkedResource do
use Ash.Resource,
domain: nil,
data_layer: Ash.DataLayer.Ets

attributes do
uuid_primary_key :id
attribute :resource_with_all_kinds_id, :uuid, public?: true
end
end

defmodule ResourceWithAllKinds do
use Ash.Resource,
domain: nil,
data_layer: Ash.DataLayer.Ets,
extensions: [AshTypescript.Resource]

typescript do
type_name "ResourceWithAllKinds"
end

attributes do
uuid_primary_key :id
attribute :first_name, :string, public?: true
end

relationships do
has_many :linked_resources, LinkedResource, public?: true
end

calculations do
calculate :display_name, :string, expr(first_name) do
public? true
end
end

aggregates do
count :linked_count, :linked_resources do
public? true
end
end
end

describe "Resource.Info.get_formatted_field/3" do
test "returns formatted name for unmapped public attribute" do
assert Info.get_formatted_field(PlainResource, :first_name, :camel_case) == "firstName"
assert Info.get_formatted_field(PlainResource, :first_name, :snake_case) == "first_name"
assert Info.get_formatted_field(PlainResource, :first_name, :pascal_case) == "FirstName"
end

test "returns the field_names override regardless of formatter" do
# Override wins for ALL builtin formatters — the DSL value is the literal
# client name with no further formatting applied.
assert Info.get_formatted_field(PlainResource, :address_line_1, :camel_case) ==
"addressLine1"

assert Info.get_formatted_field(PlainResource, :address_line_1, :snake_case) ==
"addressLine1"

assert Info.get_formatted_field(PlainResource, :address_line_1, :pascal_case) ==
"addressLine1"
end

test "applies formatter to fields without override" do
assert Info.get_formatted_field(PlainResource, :is_super_admin, :camel_case) ==
"isSuperAdmin"

assert Info.get_formatted_field(PlainResource, :is_super_admin, :pascal_case) ==
"IsSuperAdmin"
end

test "returns nil for missing field" do
assert Info.get_formatted_field(PlainResource, :nonexistent_field, :camel_case) == nil
end

test "returns nil for non-AshTypescript resource" do
assert Info.get_formatted_field(NonTypescriptResource, :name, :camel_case) == nil
end

test "returns nil for non-builtin formatter" do
# Only camel/snake/pascal are pre-computed at compile time. MFA tuples
# and any other formatter shape fall through to the runtime path.
assert Info.get_formatted_field(PlainResource, :first_name, {SomeMod, :format}) == nil
end

test "collects public attributes" do
assert Info.get_formatted_field(ResourceWithAllKinds, :first_name, :camel_case) ==
"firstName"
end

test "collects public relationships" do
assert Info.get_formatted_field(ResourceWithAllKinds, :linked_resources, :camel_case) ==
"linkedResources"
end

test "collects public calculations" do
assert Info.get_formatted_field(ResourceWithAllKinds, :display_name, :camel_case) ==
"displayName"
end

test "collects public aggregates" do
assert Info.get_formatted_field(ResourceWithAllKinds, :linked_count, :camel_case) ==
"linkedCount"
end
end

describe "format_field_for_client/3 precedence" do
test "uses persisted state for resource without callback (fast path)" do
assert FieldFormatter.format_field_for_client(:first_name, PlainResource, :camel_case) ==
"firstName"

assert FieldFormatter.format_field_for_client(:address_line_1, PlainResource, :camel_case) ==
"addressLine1"
end

test "callback takes priority over persisted DSL state" do
# ResourceWithCallback has BOTH field_names DSL AND a typescript_field_names/0
# callback. The callback must win.
assert FieldFormatter.format_field_for_client(
:is_active?,
ResourceWithCallback,
:camel_case
) == "isActiveFromCallback"
end

test "callback path falls through to formatter for fields not in callback map" do
# `:first_name` is NOT in the callback's typescript_field_names list, so
# the callback path falls through to format_field_name/2 — NOT to the DSL.
# This matches the documented behavior in compute_field_for_client/3.
assert FieldFormatter.format_field_for_client(
:first_name,
ResourceWithCallback,
:camel_case
) == "firstName"
end

test "non-typescript resource falls through to plain formatter" do
assert FieldFormatter.format_field_for_client(:name, NonTypescriptResource, :camel_case) ==
"name"
end
end
end
Loading