defmodule AshCommanded.Commanded.Transformers.GenerateProjectorModules do @moduledoc """ Generates projector modules based on the projections defined in the DSL. For each resource with projections, this transformer will generate a projector module that subscribes to the events and applies the corresponding projections to update the resource state. This transformer should run after the projection module transformer. ## Example Given a resource with several projections, this transformer will generate: ```elixir defmodule MyApp.Projectors.UserProjector do @moduledoc "Event handler that projects events onto the User resource" use Commanded.Event.Handler, application: MyApp.CommandedApplication, name: "MyApp.Projectors.UserProjector" alias MyApp.Projections def handle(%MyApp.Events.UserRegistered{} = event, _metadata) do with {:ok, resource} <- Projections.UserRegistered.apply(event), {:ok, _result} <- perform_action(resource, Projections.UserRegistered.action()) do :ok else {:error, reason} -> {:error, reason} end end # More handlers for other events... defp perform_action(changeset, :create), do: Ash.create(changeset) defp perform_action(changeset, :update), do: Ash.update(changeset) defp perform_action(changeset, :destroy), do: Ash.destroy(changeset) end ``` """ use Spark.Dsl.Transformer alias Spark.Dsl.Transformer alias AshCommanded.Commanded.Transformers.BaseTransformer alias AshCommanded.Commanded.Transformers.GenerateProjectionModules @doc """ Specifies that this transformer should run after the projection module transformer. """ @impl true def after?(GenerateProjectionModules), do: true def after?(_), do: false @doc """ Transforms the DSL state to generate projector modules. ## Examples iex> transform(dsl_state) {:ok, updated_dsl_state} """ @impl true def transform(dsl_state) do resource_module = Transformer.get_persisted(dsl_state, :module) projections = Transformer.get_entities(dsl_state, [:commanded, :projections]) events = Transformer.get_entities(dsl_state, [:commanded, :events]) # Only proceed if there are projections that should be autogenerated case Enum.filter(projections, & &1.autogenerate?) do [] -> {:ok, dsl_state} autogen_projections -> # Get the previously generated modules from DSL state event_modules = Transformer.get_persisted(dsl_state, :event_modules, []) projection_modules = Transformer.get_persisted(dsl_state, :projection_modules, []) # Build connections between events and projections event_projections = build_event_projection_map(autogen_projections, events) # Create the projector module app_prefix = BaseTransformer.get_module_prefix(resource_module) resource_name = BaseTransformer.get_resource_name(resource_module) projector_module = build_projector_module(resource_name, app_prefix) # Create the module AST and define it ast = build_projector_module_ast( resource_module, resource_name, autogen_projections, event_projections, event_modules, projection_modules ) # Skip actual module creation in test environment unless Application.get_env(:ash_commanded, :skip_projector_module_creation, Mix.env() == :test) do BaseTransformer.create_module(projector_module, ast, __ENV__) end # Store the generated module in DSL state updated_dsl_state = Transformer.persist(dsl_state, :projector_modules, [ {resource_module, projector_module} ]) {:ok, updated_dsl_state} end end @doc """ Builds a map of event names to their corresponding projections. ## Examples iex> build_event_projection_map(projections, events) %{user_registered: [projection1, projection2], email_changed: [projection3]} """ def build_event_projection_map(projections, _events) do # We include _events parameter for future enhancements # Group projections by the event they respond to projections |> Enum.group_by( fn projection -> # Use the explicit event_name or fallback to the projection name projection.event_name || projection.name end, fn projection -> projection end ) end @doc """ Builds the module name for a projector. ## Examples iex> build_projector_module("User", MyApp) MyApp.Projectors.UserProjector """ def build_projector_module(resource_name, app_prefix) do Module.concat([app_prefix, "Projectors", "#{resource_name}Projector"]) end @doc """ Builds the AST (Abstract Syntax Tree) for a projector module. ## Examples iex> build_projector_module_ast(MyApp.User, "User", projections, event_map, event_modules, projection_modules) {:__block__, [], [{:@, [...], [{:moduledoc, [...], [...]}]}, ...]} """ def build_projector_module_ast( resource_module, resource_name, _projections, event_projections, event_modules, projection_modules ) do # Create a more descriptive moduledoc moduledoc = "Event handler that projects events onto the #{resource_name} resource" # Generate handle function for each event type event_handlers = event_projections |> Enum.map(fn {event_name, event_projections} -> build_event_handler(event_name, event_projections, event_modules, projection_modules) end) # Basic application name - would need to be configurable in production application_name = :"#{resource_module}.CommandedApplication" projector_name = "#{resource_module}Projector" # Action-specific implementation functions for each Ash action action_handlers = build_action_handlers() # Check if Commanded module exists, else use stub code # This is necessary for testing environments where Commanded isn't available if Code.ensure_loaded?(Commanded) do quote do @moduledoc unquote(moduledoc) use Commanded.Event.Handler, application: unquote(application_name), name: unquote(projector_name) end else quote do @moduledoc unquote(moduledoc) # Define a stub implementation for testing def init(config), do: {:ok, config} end end |> then(fn quoted_core -> quote do unquote(quoted_core) # Common parts regardless of Commanded availability # Aliases for the projection modules alias Ash.Changeset alias Ash.Resource # Import resource module to get Ash actions import unquote(resource_module) unquote_splicing(event_handlers) unquote_splicing(action_handlers) end end) end # Build an event handler function for a specific event and its projections defp build_event_handler(event_name, projections, event_modules, projection_modules) do # Look up the event module for this event event_module = event_modules[event_name] projection_handlers = projections |> Enum.map(fn projection -> # Look up the projection module for this projection projection_module = projection_modules[projection.name] quote do with {:ok, changeset} <- unquote(projection_module).apply(event, unquote(projection.name)), {:ok, result} <- perform_action(changeset, unquote(projection_module).action()) do {:ok, result} else {:error, reason} -> {:error, reason} end end end) # Generate handler function for single projection case vs multiple case projection_handlers do [single_handler] -> # Handle a single projection directly quote do def handle(%unquote(event_module){} = event, _metadata) do unquote(single_handler) end end _multiple_handlers -> # Handle multiple projections with a multi_dispatch quote do def handle(%unquote(event_module){} = event, _metadata) do results = [ unquote_splicing(projection_handlers) ] # If any projection fails, the whole handler fails case Enum.filter(results, fn {:error, _} -> true _ -> false end) do [] -> :ok [error | _] -> error end end end end end # Build handler functions for different Ash actions defp build_action_handlers do [ quote do defp perform_action(changeset, :create), do: Ash.create(changeset) end, quote do defp perform_action(changeset, :update), do: Ash.update(changeset) end, quote do defp perform_action(changeset, :destroy), do: Ash.destroy(changeset) end, quote do defp perform_action(changeset, :read), do: Ash.read(changeset) end, quote do defp perform_action(changeset, action) when is_atom(action) do # For custom actions, use Ash.run_action Ash.run_action(changeset, action) end end ] end end