defmodule AshCommanded.Commanded.Transformers.GenerateMainRouterModule do @moduledoc """ Generates the main router module that delegates to domain-specific routers. This transformer collects all command-resource mappings from the DSL state and generates: 1. Domain-specific router modules for each domain with commands 2. A main router module that delegates to these domain routers This transformer should run after the domain router transformer has processed all resources. ## Example ```elixir # Domain-specific routers defmodule MyApp.Accounts.Router do use Commanded.Commands.Router dispatch [MyApp.Accounts.Commands.RegisterUser], to: MyApp.Accounts.UserAggregate, identity: :id end defmodule MyApp.Billing.Router do use Commanded.Commands.Router dispatch [MyApp.Billing.Commands.CreateInvoice], to: MyApp.Billing.InvoiceAggregate, identity: :id end # Main router defmodule MyApp.Router do @moduledoc "Main router for all commands in the application" use Commanded.Commands.Router # Forward commands to domain-specific routers identify MyApp.Accounts.Commands.RegisterUser, as: :accounts forward :accounts, to: MyApp.Accounts.Router identify MyApp.Billing.Commands.CreateInvoice, as: :billing forward :billing, to: MyApp.Billing.Router # Helper function to dispatch any command def dispatch_command(command) do __MODULE__.dispatch(command) end end ``` For simpler applications with a single domain, the main router may directly handle commands: ```elixir defmodule MyApp.Router do use Commanded.Commands.Router dispatch [MyApp.Commands.RegisterUser, MyApp.Commands.CreateInvoice], to: MyApp.UserAggregate, identity: :id end ``` """ use Spark.Dsl.Transformer alias Spark.Dsl.Transformer alias AshCommanded.Commanded.Transformers.BaseTransformer alias AshCommanded.Commanded.Transformers.GenerateDomainRouterModule @doc """ Specifies that this transformer should run after the domain router transformer. """ @impl true def after?(GenerateDomainRouterModule), do: true def after?(_), do: false @doc """ Transforms the DSL state to generate router modules. This transformer will: 1. Collect command mappings from all resources 2. Generate domain-specific router modules 3. Generate the main router module ## Examples iex> transform(dsl_state) {:ok, updated_dsl_state} """ @impl true def transform(dsl_state) do resource_module = Transformer.get_persisted(dsl_state, :module) # Skip if the module isn't an Ash.Domain unless is_ash_domain?(resource_module) do {:ok, dsl_state} else # Get all the command-resource mappings from the DSL state command_mappings = Transformer.get_persisted(dsl_state, :command_resource_mappings, []) # Only proceed if there are command mappings if command_mappings == [] do {:ok, dsl_state} else # Group command mappings by domain domain_mappings = Enum.group_by(command_mappings, & &1.domain) # Generate domain router modules domain_routers = generate_domain_routers(domain_mappings) # Generate the main router module app_prefix = BaseTransformer.get_module_prefix(resource_module) main_router_module = build_main_router_module(app_prefix) # Create the main router AST and define it main_router_ast = build_main_router_ast( domain_mappings, domain_routers, command_mappings ) # Skip actual module creation in test environment unless Application.get_env(:ash_commanded, :skip_router_module_creation, Mix.env() == :test) do # Create the domain router modules domain_routers |> Enum.each(fn {domain_module, router_ast} -> router_module = GenerateDomainRouterModule.build_domain_router_module(domain_module) BaseTransformer.create_module(router_module, router_ast, __ENV__) end) # Create the main router module BaseTransformer.create_module(main_router_module, main_router_ast, __ENV__) end # Store the generated module in DSL state updated_dsl_state = Transformer.persist(dsl_state, :main_router_module, main_router_module) {:ok, updated_dsl_state} end end end # Check if module is an Ash.Domain defp is_ash_domain?(module) do Code.ensure_loaded?(module) && function_exported?(module, :__ash_domain__, 0) end @doc """ Builds the module name for the main router. ## Examples iex> build_main_router_module(MyApp) MyApp.Router """ def build_main_router_module(app_prefix) do Module.concat([app_prefix, "Router"]) end # Generate the domain router modules defp generate_domain_routers(domain_mappings) do domain_mappings |> Enum.map(fn {domain_module, resource_mappings} -> # Group command modules by aggregate and identity field aggregate_groups = Enum.group_by( resource_mappings, fn mapping -> {mapping.aggregate, mapping.identity_field} end ) # Generate the router AST router_ast = build_domain_router_ast(domain_module, aggregate_groups) {domain_module, router_ast} end) end # Build the AST for a domain router defp build_domain_router_ast(domain_module, aggregate_groups) do # Create a more descriptive moduledoc domain_name = domain_module |> Module.split() |> List.last() moduledoc = "Router for commands in the #{domain_name} domain" # Generate dispatch handlers for each aggregate group dispatch_handlers = aggregate_groups |> Enum.map(fn {{aggregate_module, identity_field}, mappings} -> # Get all command modules for this aggregate command_modules = mappings |> Enum.flat_map(fn mapping -> mapping.commands |> Enum.map(fn command -> # Build the full command module name resource_prefix = BaseTransformer.get_module_prefix(mapping.resource) command_name_str = BaseTransformer.camelize_atom(command.name) command_module = Module.concat([resource_prefix, "Commands", command_name_str]) command_module end) end) # Generate the dispatch handler for this aggregate generate_aggregate_dispatch_handler(command_modules, aggregate_module, identity_field) end) # Check if Commanded is available to determine what to generate if Code.ensure_loaded?(Commanded) do quote do @moduledoc unquote(moduledoc) use Commanded.Commands.Router # Dispatch handlers for each aggregate unquote_splicing(dispatch_handlers) end else # Generate a stub module for testing when Commanded isn't available quote do @moduledoc unquote(moduledoc) # This is a stub implementation for testing # In production, this would use Commanded.Commands.Router # Stub dispatch function def dispatch(command) do {:ok, command} end end end end # Generate a dispatch handler for an aggregate defp generate_aggregate_dispatch_handler(command_modules, aggregate_module, identity_field) do quote do dispatch unquote(command_modules), to: unquote(aggregate_module), identity: unquote(identity_field) end end # Build the AST for the main router defp build_main_router_ast(domain_mappings, domain_routers, all_command_mappings) do # Create a descriptive moduledoc moduledoc = "Main router for all commands in the application" # Determine if we need to forward to domain routers has_multiple_domains = Enum.count(domain_mappings) > 1 router_body = if has_multiple_domains do # Generate forward directives for each domain forward_directives = domain_mappings |> Enum.map(fn {domain_module, resource_mappings} -> # Get all command modules for this domain all_commands = resource_mappings |> Enum.flat_map(fn mapping -> mapping.commands end) # Get the router module for this domain domain_router = GenerateDomainRouterModule.build_domain_router_module(domain_module) # Generate domain identifier (use the last part of the domain module name) domain_name = domain_module |> Module.split() |> List.last() |> String.downcase() domain_id = String.to_atom(domain_name) # Generate identify directives for commands identify_directives = all_commands |> Enum.map(fn command -> # Find the mapping for this command to get the resource module mapping = Enum.find(all_command_mappings, fn m -> Enum.any?(m.commands, fn c -> c.name == command.name end) end) if mapping do resource_prefix = BaseTransformer.get_module_prefix(mapping.resource) command_name_str = BaseTransformer.camelize_atom(command.name) command_module = Module.concat([resource_prefix, "Commands", command_name_str]) quote do identify unquote(command_module), as: unquote(domain_id) end end end) |> Enum.filter(&(&1 != nil)) # Generate forward directive for the domain identify_directives ++ [ quote do forward unquote(domain_id), to: unquote(domain_router) end ] end) |> List.flatten() # Helper function for dispatching commands helper_function = quote do @doc """ Helper function to dispatch any command with snapshot support. ## Parameters - `command` - The command to dispatch - `opts` - Additional options to pass to the dispatcher ## Returns The result of dispatching the command """ def dispatch_command(command, opts \\ []) do # Add snapshot adapter options to command dispatch snapshot_opts = AshCommanded.Commanded.SnapshotConfiguration.dispatch_options() dispatch_opts = Keyword.merge(snapshot_opts, opts) __MODULE__.dispatch(command, dispatch_opts) end end forward_directives ++ [helper_function] else # For a single domain, include direct dispatch handlers case domain_routers do [{_domain, router_ast}] -> # Extract the dispatch handlers from the domain router AST extract_dispatch_handlers(router_ast) _ -> [] end end # Check if Commanded is available to determine what to generate if Code.ensure_loaded?(Commanded) do quote do @moduledoc unquote(moduledoc) use Commanded.Commands.Router # Router body (forwards or direct dispatches) unquote_splicing(router_body) end else # Generate a stub module for testing when Commanded isn't available quote do @moduledoc unquote(moduledoc) # This is a stub implementation for testing # In production, this would use Commanded.Commands.Router # Stub dispatch function def dispatch(command) do {:ok, command} end def dispatch_command(command, _opts \\ []) do dispatch(command) end end end end # Extract dispatch handlers from domain router AST defp extract_dispatch_handlers({:__block__, _, block_contents}) do block_contents |> Enum.filter(fn node -> case node do {:dispatch, _, _} -> true _ -> false end end) end defp extract_dispatch_handlers(_) do [] end end