Skip to content

Commit

Permalink
improvement: add Ash.Generator.once/2
Browse files Browse the repository at this point in the history
This is essentially a way to "memoize" a value. To be very very clear,
this is *not* a tool for memoization/caching, it is for helping with the
complexity of lazily enumerated streams where you want to generate
something once on the first call to the stream.
  • Loading branch information
zachdaniel committed Jan 1, 2025
1 parent 9036780 commit ac7fe1b
Show file tree
Hide file tree
Showing 5 changed files with 92 additions and 10 deletions.
2 changes: 1 addition & 1 deletion lib/ash/code_interface.ex
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ defmodule Ash.CodeInterface do
the value in opts will be used instead of the default if provided. However,
certain options have special behavior:
* #{@deep_merge_keys |> Enum.map(&"`:#{&1}`") |> Enum.join(", ")} - These
* #{Enum.map_join(@deep_merge_keys, ", ", &"`:#{&1}`")} - These
options are deep merged, so if the default is a keyword list and the
client value is a keyword list, they'll be merged.
* `:load` - The default value and the client value will be combined in this
Expand Down
85 changes: 83 additions & 2 deletions lib/ash/generator/generator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,93 @@ defmodule Ash.Generator do
|> Enum.at(0)
end

@doc """
Run the provided function or enumerable (i.e generator) only once.
This is useful for ensuring that some piece of data is generated a single time during a test.
The lifecycle of this generator is tied to the process that initially starts it. In general,
that will be the test. In the rare case where you are running async processes that need to share a sequence
that is not created in the test process, you can initialize a sequence in the test using `initialize_once/1`.
Example:
iex> Ash.Generator.once(:user, fn ->
register_user(...)
end) |> Enum.at(0)
%User{id: 1} # created the user
iex> Ash.Generator.once(:user, fn ->
register_user(...)
end) |> Enum.at(0)
%User{id: 1} # reused the last user
"""
@spec once(pid | atom, (-> value) | Enumerable.t(value)) ::
StreamData.t(value)
when value: term
def once(identifier, generator) do
pid =
if is_pid(identifier) do
identifier
else
initialize_once(identifier)
end

StreamData.repeatedly(fn ->
Agent.get_and_update(pid, fn state ->
case state do
:none ->
new =
case generator do
generator when is_function(generator) ->
generator.()

value ->
Enum.at(value, 0)
end

{new, {:some, new}}

{:some, value} ->
{value, {:some, value}}
end
end)
end)
end

@doc """
Starts and links an agent for a `once/2`, or returns the existing agent pid if it already exists.
See `once/2` for more.
"""
# sobelow_skip ["DOS.BinToAtom"]
@spec initialize_once(atom) :: pid
def initialize_once(identifier) do
identifier = :"__ash_once_#{identifier}__"

case Agent.start_link(fn -> :none end, name: identifier) do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
end
end

@doc """
Stops the agent for a `once/2`.
See `once/2` for more.
"""
def stop_once(identifier) do
Agent.stop(identifier)
:ok
end

@doc """
Generate globally unique values.
This is useful for generating values that are unique across all resources, such as email addresses,
or for generating values that are unique across a single resource, such as identifiers. The values will be unique
for anything using the same sequence name.
The name of the identifier will be used as the name of the agent process, so use a unique name not in use anywhere else.
The lifecycle of this generator is tied to the process that initially starts it. In general,
that will be the test. In the rare case where you are running async processes that need to share a sequence
that is not created in the test process, you can initialize a sequence in the test using `initialize_sequence/1`.
Expand Down Expand Up @@ -141,7 +219,10 @@ defmodule Ash.Generator do
See `sequence/3` for more.
"""
@spec initialize_sequence(atom) :: pid
# sobelow_skip ["DOS.BinToAtom"]
def initialize_sequence(identifier) do
identifier = :"__ash_sequence_#{identifier}__"

case Agent.start_link(fn -> nil end, name: identifier) do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
Expand Down
2 changes: 1 addition & 1 deletion lib/ash/type/map.ex
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ defmodule Ash.Type.Map do
end)
|> Ash.Generator.mixed_map(optional)
else
StreamData.repeatedly(%{})
StreamData.constant(%{})
end
end

Expand Down
10 changes: 5 additions & 5 deletions lib/ash/type/struct.ex
Original file line number Diff line number Diff line change
Expand Up @@ -190,12 +190,12 @@ defmodule Ash.Type.Struct do
if !constraints[:instance_of] do
raise ArgumentError,
"Cannot generate instances of the `:struct` type without an `:instance_of` constraint"
else
Ash.Type.Map.generator(constraints)
|> StreamData.map(fn value ->
struct(constraints[:instance_of], value)
end)
end

Ash.Type.Map.generator(constraints)
|> StreamData.map(fn value ->
struct(constraints[:instance_of], value)
end)
end

@impl true
Expand Down
3 changes: 2 additions & 1 deletion test/generator/generator_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,8 @@ defmodule Ash.Test.GeneratorTest do
end

describe "built in generators" do
for type <- Enum.uniq(Ash.Type.builtin_types()), type != Ash.Type.Keyword do
for type <- Enum.uniq(Ash.Type.builtin_types()),
type not in [Ash.Type.Struct, Ash.Type.Keyword, Ash.Type.Map] do
for type <- [{:array, type}, type] do
constraints =
case type do
Expand Down

0 comments on commit ac7fe1b

Please sign in to comment.