From 56bb7982d9a6f476f43e5510c30cdb30dcf1ff79 Mon Sep 17 00:00:00 2001 From: Christian Alexander Date: Tue, 3 Feb 2026 06:33:10 -0700 Subject: [PATCH 1/2] feat: add object_keys option to control key conversion on deserialization Allow users to configure how string keys within field values are converted when loading state from JSON. Supports :strings (default, no conversion), :atoms! (existing atoms only), and :atoms (creates atoms if needed). Configurable via DSL or application config. --- lib/durable_object/dsl/extension.ex | 5 + .../dsl/transformers/build_introspection.ex | 6 +- lib/durable_object/server.ex | 50 +- .../server_object_keys_test.exs | 690 ++++++++++++++++++ 4 files changed, 744 insertions(+), 7 deletions(-) create mode 100644 test/durable_object/server_object_keys_test.exs diff --git a/lib/durable_object/dsl/extension.ex b/lib/durable_object/dsl/extension.ex index 0cc3425..702f94d 100644 --- a/lib/durable_object/dsl/extension.ex +++ b/lib/durable_object/dsl/extension.ex @@ -76,6 +76,11 @@ defmodule DurableObject.Dsl.Extension do type: {:or, [:pos_integer, {:literal, :infinity}, nil]}, default: nil, doc: "Stop process after this many ms of inactivity (nil = never)" + ], + object_keys: [ + type: {:in, [:strings, :atoms!, :atoms]}, + doc: + "How to convert string keys within field values when loading from JSON. :strings (default, no conversion), :atoms! (existing atoms only, raises otherwise), :atoms (creates atoms if needed)." ] ] } diff --git a/lib/durable_object/dsl/transformers/build_introspection.ex b/lib/durable_object/dsl/transformers/build_introspection.ex index 43fd65e..54cd7d4 100644 --- a/lib/durable_object/dsl/transformers/build_introspection.ex +++ b/lib/durable_object/dsl/transformers/build_introspection.ex @@ -22,6 +22,7 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do handlers = Transformer.get_entities(dsl_state, [:handlers]) hibernate_after = Transformer.get_option(dsl_state, [:options], :hibernate_after) || 300_000 shutdown_after = Transformer.get_option(dsl_state, [:options], :shutdown_after) + object_keys = Transformer.get_option(dsl_state, [:options], :object_keys) # Build default state map from fields default_state = @@ -37,6 +38,7 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do |> Transformer.persist(:durable_object_hibernate_after, hibernate_after) |> Transformer.persist(:durable_object_shutdown_after, shutdown_after) |> Transformer.persist(:durable_object_default_state, default_state) + |> Transformer.persist(:durable_object_object_keys, object_keys) # Convert structs to a format that can be safely used in quoted expressions fields_data = Enum.map(fields, &Map.from_struct/1) @@ -51,7 +53,8 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do handlers_data: handlers_data, hibernate_after: hibernate_after, shutdown_after: shutdown_after, - default_state: default_state + default_state: default_state, + object_keys: object_keys ], quote do @doc false @@ -70,6 +73,7 @@ defmodule DurableObject.Dsl.Transformers.BuildIntrospection do def __durable_object__(:hibernate_after), do: unquote(hibernate_after) def __durable_object__(:shutdown_after), do: unquote(shutdown_after) def __durable_object__(:default_state), do: unquote(Macro.escape(default_state)) + def __durable_object__(:object_keys), do: unquote(object_keys) end ) diff --git a/lib/durable_object/server.ex b/lib/durable_object/server.ex index 7d03011..6fe699e 100644 --- a/lib/durable_object/server.ex +++ b/lib/durable_object/server.ex @@ -7,7 +7,7 @@ defmodule DurableObject.Server do @default_hibernate_after :timer.minutes(5) - defstruct [:module, :object_id, :state, :shutdown_after, :shutdown_timer, :repo, :prefix] + defstruct [:module, :object_id, :state, :shutdown_after, :shutdown_timer, :repo, :prefix, :object_keys] # --- Client API --- @@ -117,6 +117,7 @@ defmodule DurableObject.Server do repo = Keyword.get(opts, :repo) prefix = Keyword.get(opts, :prefix) default_state = module.__durable_object__(:default_state) + object_keys = get_object_keys_config(module) server = %__MODULE__{ module: module, @@ -125,7 +126,8 @@ defmodule DurableObject.Server do shutdown_after: shutdown_after, shutdown_timer: nil, repo: repo, - prefix: prefix + prefix: prefix, + object_keys: object_keys } if repo do @@ -159,7 +161,7 @@ defmodule DurableObject.Server do {:ok, object} -> # Atomize string keys from JSON, merge with defaults for missing fields - loaded_state = atomize_keys(object.state) + loaded_state = atomize_keys(object.state, server.object_keys) merged_state = Map.merge(server.state, loaded_state) run_after_load(%{server | state: merged_state}) @@ -344,10 +346,46 @@ defmodule DurableObject.Server do end end - defp atomize_keys(map) when is_map(map) do + # Field names (from DSL) are atomized; object_keys controls keys within field values + defp atomize_keys(map, object_keys) when is_map(map) do Map.new(map, fn - {key, value} when is_binary(key) -> {String.to_existing_atom(key), value} - {key, value} -> {key, value} + {key, value} when is_binary(key) -> + {String.to_existing_atom(key), convert_value(value, object_keys)} + + {key, value} -> + {key, convert_value(value, object_keys)} + end) + end + + defp convert_value(value, :strings), do: value + + defp convert_value(map, strategy) when is_map(map) do + Map.new(map, fn + {key, value} when is_binary(key) -> + {convert_key(key, strategy), convert_value(value, strategy)} + + {key, value} -> + {key, convert_value(value, strategy)} end) end + + defp convert_value(list, strategy) when is_list(list) do + Enum.map(list, &convert_value(&1, strategy)) + end + + defp convert_value(value, _strategy), do: value + + defp convert_key(key, :atoms!), do: String.to_existing_atom(key) + defp convert_key(key, :atoms), do: String.to_atom(key) + + defp get_object_keys_config(module) do + dsl_value = + if function_exported?(module, :__durable_object__, 1) do + module.__durable_object__(:object_keys) + else + nil + end + + dsl_value || Application.get_env(:durable_object, :object_keys, :strings) + end end diff --git a/test/durable_object/server_object_keys_test.exs b/test/durable_object/server_object_keys_test.exs new file mode 100644 index 0000000..f1045a3 --- /dev/null +++ b/test/durable_object/server_object_keys_test.exs @@ -0,0 +1,690 @@ +defmodule DurableObject.ServerObjectKeysTest do + use ExUnit.Case + + alias DurableObject.{Server, Storage, TestRepo} + import DurableObject.TestHelpers + + # Default behavior - keys within field values stay as strings + defmodule DefaultKeysObject do + use DurableObject + + state do + field(:metadata, :map, default: %{}) + field(:items, :list, default: []) + end + + handlers do + handler(:get) + handler(:set_metadata, args: [:data]) + end + + def handle_get(state) do + {:reply, state, state} + end + + def handle_set_metadata(data, state) do + {:reply, :ok, %{state | metadata: data}} + end + end + + # Explicit :strings option - same as default + defmodule StringKeysObject do + use DurableObject + + options do + object_keys :strings + end + + state do + field(:metadata, :map, default: %{}) + end + + handlers do + handler(:get) + end + + def handle_get(state) do + {:reply, state, state} + end + end + + # :atoms! option - converts to existing atoms only + defmodule ExistingAtomKeysObject do + use DurableObject + + options do + object_keys :atoms! + end + + state do + field(:metadata, :map, default: %{}) + field(:items, :list, default: []) + end + + handlers do + handler(:get) + end + + def handle_get(state) do + {:reply, state, state} + end + end + + # :atoms option - creates atoms if needed + defmodule CreateAtomKeysObject do + use DurableObject + + options do + object_keys :atoms + end + + state do + field(:metadata, :map, default: %{}) + field(:items, :list, default: []) + end + + handlers do + handler(:get) + end + + def handle_get(state) do + {:reply, state, state} + end + end + + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(TestRepo) + Ecto.Adapters.SQL.Sandbox.mode(TestRepo, {:shared, self()}) + :ok + end + + describe "introspection" do + test "default object_keys is nil (deferred to runtime)" do + assert DefaultKeysObject.__durable_object__(:object_keys) == nil + end + + test "explicit :strings option" do + assert StringKeysObject.__durable_object__(:object_keys) == :strings + end + + test ":atoms! option is accessible" do + assert ExistingAtomKeysObject.__durable_object__(:object_keys) == :atoms! + end + + test ":atoms option is accessible" do + assert CreateAtomKeysObject.__durable_object__(:object_keys) == :atoms + end + end + + describe "default behavior (:strings)" do + test "keeps keys within field values as strings" do + id = unique_id("default-keys") + + # Pre-populate with nested string keys (as stored in JSON) + {:ok, _} = + Storage.save(TestRepo, "#{DefaultKeysObject}", id, %{ + "metadata" => %{"foo" => "bar", "nested" => %{"baz" => 123}}, + "items" => [%{"name" => "item1"}, %{"name" => "item2"}] + }) + + {:ok, _pid} = + Server.start_link( + module: DefaultKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(DefaultKeysObject, id, :get) + + # Field names are atoms (DSL handles this) + assert Map.has_key?(state, :metadata) + assert Map.has_key?(state, :items) + + # Keys within values remain strings + assert state.metadata == %{"foo" => "bar", "nested" => %{"baz" => 123}} + assert state.items == [%{"name" => "item1"}, %{"name" => "item2"}] + end + end + + describe ":atoms! option" do + test "converts keys within field values to existing atoms" do + id = unique_id("existing-atoms") + + # Ensure atoms exist + _ = [:foo, :nested, :baz, :name] + + # Pre-populate with nested string keys + {:ok, _} = + Storage.save(TestRepo, "#{ExistingAtomKeysObject}", id, %{ + "metadata" => %{"foo" => "bar", "nested" => %{"baz" => 123}}, + "items" => [%{"name" => "item1"}, %{"name" => "item2"}] + }) + + {:ok, _pid} = + Server.start_link( + module: ExistingAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(ExistingAtomKeysObject, id, :get) + + # Field names are atoms + assert Map.has_key?(state, :metadata) + assert Map.has_key?(state, :items) + + # Keys within values are converted to atoms + assert state.metadata == %{foo: "bar", nested: %{baz: 123}} + assert state.items == [%{name: "item1"}, %{name: "item2"}] + end + + test "raises for non-existent atoms" do + id = unique_id("nonexistent-atoms") + + # Pre-populate with a key that doesn't exist as an atom + random_key = "nonexistent_key_#{System.unique_integer()}" + + {:ok, _} = + Storage.save(TestRepo, "#{ExistingAtomKeysObject}", id, %{ + "metadata" => %{random_key => "value"}, + "items" => [] + }) + + # The server starts but crashes during handle_continue when loading state + Process.flag(:trap_exit, true) + + {:ok, pid} = + Server.start_link( + module: ExistingAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + # Wait for the process to crash + assert_receive {:EXIT, ^pid, reason}, 1000 + assert reason != :normal + end + end + + describe ":atoms option" do + test "creates atoms for keys within field values" do + id = unique_id("create-atoms") + + # Use a unique key that definitely doesn't exist as an atom yet + unique_key = "dynamically_created_#{System.unique_integer()}" + + {:ok, _} = + Storage.save(TestRepo, "#{CreateAtomKeysObject}", id, %{ + "metadata" => %{unique_key => "value", "regular" => "data"}, + "items" => [] + }) + + {:ok, _pid} = + Server.start_link( + module: CreateAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(CreateAtomKeysObject, id, :get) + + # Keys are converted to atoms (including the dynamically created one) + assert Map.has_key?(state.metadata, String.to_atom(unique_key)) + assert Map.has_key?(state.metadata, :regular) + assert state.metadata[String.to_atom(unique_key)] == "value" + assert state.metadata[:regular] == "data" + end + + test "recursively converts keys in deeply nested maps" do + id = unique_id("atoms-deep") + + outer_key = "atoms_deep_outer_#{System.unique_integer()}" + inner_key = "atoms_deep_inner_#{System.unique_integer()}" + + {:ok, _} = + Storage.save(TestRepo, "#{CreateAtomKeysObject}", id, %{ + "metadata" => %{outer_key => %{inner_key => "deep"}}, + "items" => [] + }) + + {:ok, _pid} = + Server.start_link( + module: CreateAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(CreateAtomKeysObject, id, :get) + + outer_atom = String.to_atom(outer_key) + inner_atom = String.to_atom(inner_key) + assert state.metadata == %{outer_atom => %{inner_atom => "deep"}} + end + + test "recursively converts keys in lists of maps" do + id = unique_id("atoms-list") + + key = "atoms_list_key_#{System.unique_integer()}" + + {:ok, _} = + Storage.save(TestRepo, "#{CreateAtomKeysObject}", id, %{ + "metadata" => %{}, + "items" => [%{key => "a"}, %{key => "b"}] + }) + + {:ok, _pid} = + Server.start_link( + module: CreateAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(CreateAtomKeysObject, id, :get) + + key_atom = String.to_atom(key) + assert state.items == [%{key_atom => "a"}, %{key_atom => "b"}] + end + end + + describe "deeply nested structures" do + test "recursively converts keys in nested maps with :atoms!" do + id = unique_id("deep-nested") + + # Ensure atoms exist + _ = [:level1, :level2, :level3, :value] + + {:ok, _} = + Storage.save(TestRepo, "#{ExistingAtomKeysObject}", id, %{ + "metadata" => %{ + "level1" => %{ + "level2" => %{ + "level3" => %{"value" => "deep"} + } + } + }, + "items" => [] + }) + + {:ok, _pid} = + Server.start_link( + module: ExistingAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(ExistingAtomKeysObject, id, :get) + + assert state.metadata == %{ + level1: %{ + level2: %{ + level3: %{value: "deep"} + } + } + } + end + + test "recursively converts keys in lists of maps with :atoms!" do + id = unique_id("list-of-maps") + + # Ensure atoms exist + _ = [:id, :data, :nested] + + {:ok, _} = + Storage.save(TestRepo, "#{ExistingAtomKeysObject}", id, %{ + "metadata" => %{}, + "items" => [ + %{"id" => 1, "data" => %{"nested" => "value1"}}, + %{"id" => 2, "data" => %{"nested" => "value2"}} + ] + }) + + {:ok, _pid} = + Server.start_link( + module: ExistingAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(ExistingAtomKeysObject, id, :get) + + assert state.items == [ + %{id: 1, data: %{nested: "value1"}}, + %{id: 2, data: %{nested: "value2"}} + ] + end + end + + describe "application config fallback" do + test "uses application config when DSL not specified" do + id = unique_id("app-config") + + # Ensure atoms exist + _ = [:app_key] + + # Set application config + original_value = Application.get_env(:durable_object, :object_keys) + Application.put_env(:durable_object, :object_keys, :atoms!) + + try do + {:ok, _} = + Storage.save(TestRepo, "#{DefaultKeysObject}", id, %{ + "metadata" => %{"app_key" => "value"}, + "items" => [] + }) + + {:ok, _pid} = + Server.start_link( + module: DefaultKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(DefaultKeysObject, id, :get) + + # Should use :atoms! from app config since DSL defaults to :strings (nil in config resolution) + assert state.metadata == %{app_key: "value"} + after + if original_value do + Application.put_env(:durable_object, :object_keys, original_value) + else + Application.delete_env(:durable_object, :object_keys) + end + end + end + + test "DSL config overrides application config" do + id = unique_id("dsl-override") + + # Set application config to :atoms + original_value = Application.get_env(:durable_object, :object_keys) + Application.put_env(:durable_object, :object_keys, :atoms) + + try do + {:ok, _} = + Storage.save(TestRepo, "#{StringKeysObject}", id, %{ + "metadata" => %{"key" => "value"} + }) + + {:ok, _pid} = + Server.start_link( + module: StringKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(StringKeysObject, id, :get) + + # Should use :strings from DSL, not :atoms from app config + assert state.metadata == %{"key" => "value"} + after + if original_value do + Application.put_env(:durable_object, :object_keys, original_value) + else + Application.delete_env(:durable_object, :object_keys) + end + end + end + end + + describe "edge cases" do + test "empty maps and lists are preserved" do + id = unique_id("edge-empty") + + {:ok, _} = + Storage.save(TestRepo, "#{ExistingAtomKeysObject}", id, %{ + "metadata" => %{}, + "items" => [] + }) + + {:ok, _pid} = + Server.start_link( + module: ExistingAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(ExistingAtomKeysObject, id, :get) + + assert state.metadata == %{} + assert state.items == [] + end + + test "nil and scalar values within fields are preserved" do + id = unique_id("edge-scalars") + + _ = [:str, :num, :bool, :null_val] + + {:ok, _} = + Storage.save(TestRepo, "#{ExistingAtomKeysObject}", id, %{ + "metadata" => %{"str" => "hello", "num" => 42, "bool" => true, "null_val" => nil}, + "items" => [] + }) + + {:ok, _pid} = + Server.start_link( + module: ExistingAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(ExistingAtomKeysObject, id, :get) + + assert state.metadata == %{str: "hello", num: 42, bool: true, null_val: nil} + end + + test "scalar items in lists are not modified" do + id = unique_id("edge-scalar-list") + + _ = [:tags] + + {:ok, _} = + Storage.save(TestRepo, "#{ExistingAtomKeysObject}", id, %{ + "metadata" => %{"tags" => ["alpha", "beta", 3, true, nil]}, + "items" => [1, "two", nil, false] + }) + + {:ok, _pid} = + Server.start_link( + module: ExistingAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(ExistingAtomKeysObject, id, :get) + + assert state.metadata == %{tags: ["alpha", "beta", 3, true, nil]} + assert state.items == [1, "two", nil, false] + end + + test "nested lists of lists are traversed" do + id = unique_id("edge-nested-list") + + _ = [:key] + + {:ok, _} = + Storage.save(TestRepo, "#{ExistingAtomKeysObject}", id, %{ + "metadata" => %{}, + "items" => [[%{"key" => "inner"}], [1, 2]] + }) + + {:ok, _pid} = + Server.start_link( + module: ExistingAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(ExistingAtomKeysObject, id, :get) + + assert state.items == [[%{key: "inner"}], [1, 2]] + end + + test "non-string keys in nested maps are preserved with :strings" do + id = unique_id("edge-mixed-keys-strings") + + # Simulate a map that already has atom keys (e.g. from default merging) + # plus string keys from JSON - :strings should leave values untouched + {:ok, _} = + Storage.save(TestRepo, "#{DefaultKeysObject}", id, %{ + "metadata" => %{"a" => 1}, + "items" => [] + }) + + {:ok, _pid} = + Server.start_link( + module: DefaultKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(DefaultKeysObject, id, :get) + + # Value map is untouched - keys remain strings + assert state.metadata == %{"a" => 1} + end + + test "already-atom keys in nested maps are preserved with :atoms!" do + id = unique_id("edge-atom-keys") + + _ = [:existing_key] + + # Stored state only has string keys from JSON, but verify the convert_value + # non-string-key branch by saving a map with an already-atom key. + # In practice JSON always produces string keys, but the code handles mixed maps. + {:ok, _} = + Storage.save(TestRepo, "#{ExistingAtomKeysObject}", id, %{ + "metadata" => %{"existing_key" => "val"}, + "items" => [] + }) + + {:ok, _pid} = + Server.start_link( + module: ExistingAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(ExistingAtomKeysObject, id, :get) + + assert state.metadata == %{existing_key: "val"} + end + end + + describe ":strings with nested structures" do + test "preserves string keys in lists of maps" do + id = unique_id("strings-list") + + {:ok, _} = + Storage.save(TestRepo, "#{DefaultKeysObject}", id, %{ + "metadata" => %{}, + "items" => [%{"a" => 1, "b" => %{"c" => 2}}, %{"d" => 3}] + }) + + {:ok, _pid} = + Server.start_link( + module: DefaultKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(DefaultKeysObject, id, :get) + + assert state.items == [%{"a" => 1, "b" => %{"c" => 2}}, %{"d" => 3}] + end + + test "preserves string keys in deeply nested maps" do + id = unique_id("strings-deep") + + {:ok, _} = + Storage.save(TestRepo, "#{DefaultKeysObject}", id, %{ + "metadata" => %{"a" => %{"b" => %{"c" => "deep"}}}, + "items" => [] + }) + + {:ok, _pid} = + Server.start_link( + module: DefaultKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(DefaultKeysObject, id, :get) + + assert state.metadata == %{"a" => %{"b" => %{"c" => "deep"}}} + end + end + + describe "field names always atomized" do + test "field names are atoms regardless of object_keys setting" do + id = unique_id("field-names") + + {:ok, _} = + Storage.save(TestRepo, "#{DefaultKeysObject}", id, %{ + "metadata" => %{"key" => "value"}, + "items" => [] + }) + + {:ok, _pid} = + Server.start_link( + module: DefaultKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(DefaultKeysObject, id, :get) + + assert Map.has_key?(state, :metadata) + assert Map.has_key?(state, :items) + refute Map.has_key?(state, "metadata") + refute Map.has_key?(state, "items") + end + + test "field names are atoms with :atoms! option" do + id = unique_id("field-names-atoms!") + + {:ok, _} = + Storage.save(TestRepo, "#{ExistingAtomKeysObject}", id, %{ + "metadata" => %{}, + "items" => [] + }) + + {:ok, _pid} = + Server.start_link( + module: ExistingAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(ExistingAtomKeysObject, id, :get) + + assert Map.has_key?(state, :metadata) + assert Map.has_key?(state, :items) + refute Map.has_key?(state, "metadata") + refute Map.has_key?(state, "items") + end + + test "field names are atoms with :atoms option" do + id = unique_id("field-names-atoms") + + {:ok, _} = + Storage.save(TestRepo, "#{CreateAtomKeysObject}", id, %{ + "metadata" => %{}, + "items" => [] + }) + + {:ok, _pid} = + Server.start_link( + module: CreateAtomKeysObject, + object_id: id, + repo: TestRepo + ) + + {:ok, state} = Server.call(CreateAtomKeysObject, id, :get) + + assert Map.has_key?(state, :metadata) + assert Map.has_key?(state, :items) + refute Map.has_key?(state, "metadata") + refute Map.has_key?(state, "items") + end + end +end From 07d160539531b65152f59af3d97c106d1f3ab809 Mon Sep 17 00:00:00 2001 From: Christian Alexander Date: Tue, 3 Feb 2026 07:12:22 -0700 Subject: [PATCH 2/2] review fixes: clean up object_keys implementation and documentation - Use DurableObject.Testing in server_object_keys_test instead of manual sandbox setup - Remove unnecessary function_exported? guard in get_object_keys_config - Format defstruct as multi-line - Document object_keys in README, usage-rules, changelog, and testing.ex --- CHANGELOG.md | 6 ++++++ README.md | 1 + lib/durable_object/server.ex | 21 +++++++++++-------- lib/durable_object/testing.ex | 6 +++++- .../server_object_keys_test.exs | 14 +++++-------- usage-rules.md | 1 + 6 files changed, 30 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9f768..d07dd0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `object_keys` option to control how string keys within field values are converted when loading state from JSON + - `:strings` (default) - leaves keys as strings + - `:atoms!` - converts to existing atoms only (raises on unknown keys) + - `:atoms` - creates atoms as needed (use with caution) + - Configurable per-object in the DSL `options` block, or globally via `config :durable_object, object_keys: :atoms!` + - DSL setting takes precedence over application config - `DurableObject.Testing` module with ergonomic test helpers - `use DurableObject.Testing, repo: MyApp.Repo` sets up Ecto sandbox and imports helpers - Unit testing: `perform_handler/4` and `perform_alarm_handler/3` for testing handler logic in isolation diff --git a/README.md b/README.md index d3c92bc..45da67d 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ mix ecto.migrate config :durable_object, repo: MyApp.Repo, registry_mode: :local, # or :horde for distributed + object_keys: :strings, # :strings | :atoms! | :atoms — controls map key conversion on load scheduler: DurableObject.Scheduler.Polling, scheduler_opts: [ polling_interval: :timer.seconds(30), diff --git a/lib/durable_object/server.ex b/lib/durable_object/server.ex index 6fe699e..d3015db 100644 --- a/lib/durable_object/server.ex +++ b/lib/durable_object/server.ex @@ -7,7 +7,16 @@ defmodule DurableObject.Server do @default_hibernate_after :timer.minutes(5) - defstruct [:module, :object_id, :state, :shutdown_after, :shutdown_timer, :repo, :prefix, :object_keys] + defstruct [ + :module, + :object_id, + :state, + :shutdown_after, + :shutdown_timer, + :repo, + :prefix, + :object_keys + ] # --- Client API --- @@ -379,13 +388,7 @@ defmodule DurableObject.Server do defp convert_key(key, :atoms), do: String.to_atom(key) defp get_object_keys_config(module) do - dsl_value = - if function_exported?(module, :__durable_object__, 1) do - module.__durable_object__(:object_keys) - else - nil - end - - dsl_value || Application.get_env(:durable_object, :object_keys, :strings) + module.__durable_object__(:object_keys) || + Application.get_env(:durable_object, :object_keys, :strings) end end diff --git a/lib/durable_object/testing.ex b/lib/durable_object/testing.ex index 5c40dba..78bb015 100644 --- a/lib/durable_object/testing.ex +++ b/lib/durable_object/testing.ex @@ -567,7 +567,8 @@ defmodule DurableObject.Testing do Returns the persisted state for an object, or `nil` if not found. Useful for custom assertions beyond what `assert_persisted/4` provides. - Keys are returned as atoms. + Top-level field keys are returned as atoms. Keys within field values + remain as strings (the raw DB form), regardless of the `object_keys` setting. ## Options @@ -580,6 +581,9 @@ defmodule DurableObject.Testing do assert state.count > 0 assert state.name =~ ~r/test/ + # Nested keys are always strings, even with object_keys: :atoms! + assert state.metadata == %{"foo" => "bar"} + # Returns nil if not persisted assert nil == get_persisted_state(Counter, "nonexistent") """ diff --git a/test/durable_object/server_object_keys_test.exs b/test/durable_object/server_object_keys_test.exs index f1045a3..9b1212d 100644 --- a/test/durable_object/server_object_keys_test.exs +++ b/test/durable_object/server_object_keys_test.exs @@ -1,5 +1,6 @@ defmodule DurableObject.ServerObjectKeysTest do use ExUnit.Case + use DurableObject.Testing, repo: DurableObject.TestRepo alias DurableObject.{Server, Storage, TestRepo} import DurableObject.TestHelpers @@ -32,7 +33,7 @@ defmodule DurableObject.ServerObjectKeysTest do use DurableObject options do - object_keys :strings + object_keys(:strings) end state do @@ -53,7 +54,7 @@ defmodule DurableObject.ServerObjectKeysTest do use DurableObject options do - object_keys :atoms! + object_keys(:atoms!) end state do @@ -75,7 +76,7 @@ defmodule DurableObject.ServerObjectKeysTest do use DurableObject options do - object_keys :atoms + object_keys(:atoms) end state do @@ -92,12 +93,6 @@ defmodule DurableObject.ServerObjectKeysTest do end end - setup do - :ok = Ecto.Adapters.SQL.Sandbox.checkout(TestRepo) - Ecto.Adapters.SQL.Sandbox.mode(TestRepo, {:shared, self()}) - :ok - end - describe "introspection" do test "default object_keys is nil (deferred to runtime)" do assert DefaultKeysObject.__durable_object__(:object_keys) == nil @@ -178,6 +173,7 @@ defmodule DurableObject.ServerObjectKeysTest do assert state.items == [%{name: "item1"}, %{name: "item2"}] end + @tag capture_log: true test "raises for non-existent atoms" do id = unique_id("nonexistent-atoms") diff --git a/usage-rules.md b/usage-rules.md index 4b9fe3d..962df1c 100644 --- a/usage-rules.md +++ b/usage-rules.md @@ -110,6 +110,7 @@ end config :durable_object, repo: MyApp.Repo, registry_mode: :local, # or :horde + object_keys: :strings, # :strings | :atoms! | :atoms — map key conversion on load scheduler: DurableObject.Scheduler.Polling, scheduler_opts: [ polling_interval: :timer.seconds(30),