From b2d30bf2ccad6b77b582bab3c9573e69af734be2 Mon Sep 17 00:00:00 2001 From: Mike Wilson Date: Thu, 30 Apr 2026 00:21:47 -0700 Subject: [PATCH] refactor(field_formatter): use Spark transformer for field-name cache Replaces the per-process-dictionary cache from #68 with a compile time Spark transformer that persists every (field, formatter) pair via `Spark.Dsl.Transformer.persist/3`. Runtime lookup is one `Spark.Dsl.Extension.get_persisted/2` call. Preserves the `typescript_field_names/0` callback precedence over the DSL. --- lib/ash_typescript/field_formatter.ex | 46 +--- lib/ash_typescript/resource.ex | 3 + lib/ash_typescript/resource/info.ex | 26 +++ .../transformers/persist_formatted_fields.ex | 50 +++++ .../persist_formatted_fields_test.exs | 209 ++++++++++++++++++ 5 files changed, 298 insertions(+), 36 deletions(-) create mode 100644 lib/ash_typescript/resource/transformers/persist_formatted_fields.ex create mode 100644 test/ash_typescript/resource/transformers/persist_formatted_fields_test.exs diff --git a/lib/ash_typescript/field_formatter.ex b/lib/ash_typescript/field_formatter.ex index c64eb354..c256b179 100644 --- a/lib/ash_typescript/field_formatter.ex +++ b/lib/ash_typescript/field_formatter.ex @@ -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 @@ -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 diff --git a/lib/ash_typescript/resource.ex b/lib/ash_typescript/resource.ex index 29cca72e..d6463a60 100644 --- a/lib/ash_typescript/resource.ex +++ b/lib/ash_typescript/resource.ex @@ -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, diff --git a/lib/ash_typescript/resource/info.ex b/lib/ash_typescript/resource/info.ex index 9960fafe..aebf397c 100644 --- a/lib/ash_typescript/resource/info.ex +++ b/lib/ash_typescript/resource/info.ex @@ -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. diff --git a/lib/ash_typescript/resource/transformers/persist_formatted_fields.ex b/lib/ash_typescript/resource/transformers/persist_formatted_fields.ex new file mode 100644 index 00000000..471bde8f --- /dev/null +++ b/lib/ash_typescript/resource/transformers/persist_formatted_fields.ex @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript 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 diff --git a/test/ash_typescript/resource/transformers/persist_formatted_fields_test.exs b/test/ash_typescript/resource/transformers/persist_formatted_fields_test.exs new file mode 100644 index 00000000..527b982b --- /dev/null +++ b/test/ash_typescript/resource/transformers/persist_formatted_fields_test.exs @@ -0,0 +1,209 @@ +# SPDX-FileCopyrightText: 2025 ash_typescript 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