lib/chronicle/read_model.ex

# Copyright (c) Cratis. All rights reserved.
# Licensed under the MIT license. See LICENSE file in the project root for full license information.

defmodule Chronicle.ReadModel do
  @moduledoc """
  Macro for defining Chronicle read models with embedded model-bound projections.

  Use `Chronicle.ReadModel` in a struct module to define both the read model
  shape and how Chronicle should project events into it. The projection
  definition lives right next to the struct fields — no separate projection
  module is needed.

  ## Quick Example

      defmodule MyApp.ReadModels.Account do
        use Chronicle.ReadModel

        defstruct account_id: nil, owner_name: nil, balance: 0, transaction_count: 0

        from MyApp.Events.AccountOpened,
          key: "$eventSourceId",
          set: [
            account_id: "$eventSourceId",
            owner_name: "OwnerName",
            balance: "InitialBalance"
          ]

        from MyApp.Events.FundsDeposited,
          key: "$eventSourceId",
          add: [balance: "Amount", transaction_count: 1]

        from MyApp.Events.FundsWithdrawn,
          key: "$eventSourceId",
          subtract: [balance: "Amount"],
          add: [transaction_count: 1]
      end

  ## Projection Macros

  ### `from/2`

  Maps properties from an event onto the read model.

      from MyApp.Events.AccountOpened,
        key: "$eventSourceId",
        set: [account_id: "$eventSourceId", owner_name: "OwnerName"],
        count: :transaction_count

  Options:
    * `:key` — key expression identifying the model instance (default: `"$eventSourceId"`)
    * `:parent_key` — parent key for nested models
    * `:set` — keyword list of `field: expression` pairs to set directly
    * `:add` — keyword list of `field: expression` pairs to add to
    * `:subtract` — keyword list of `field: expression` pairs to subtract from
    * `:count` — field atom to increment by 1 on each event occurrence

  ### `join/2`

  Joins a secondary event onto the model by a matching field.

      join MyApp.Events.AccountVerified,
        on: "AccountId",
        key: "$eventSourceId",
        set: [verified: true]

  Options:
    * `:on` — **(required)** the field name in the event to join on
    * `:key` — key expression (default: `"$eventSourceId"`)
    * `:set`, `:add`, `:subtract` — property mappings

  ### `removed_with/2`

  Removes the model instance when the given event occurs.

      removed_with MyApp.Events.AccountClosed,
        key: "$eventSourceId"

  Options:
    * `:key` — key expression (default: `"$eventSourceId"`)
    * `:parent_key` — parent key for nested models

  ### `from_every/1`

  Applies property mappings on every event, regardless of type.

      from_every set: [last_activity: "Occurred"]

  Options:
    * `:set`, `:add`, `:subtract` — property mappings applied to every event

  ## Property Expressions

  Values in `set:`, `add:`, and `subtract:` lists are Chronicle property path
  expressions:

  | Expression | Meaning |
  |-----------|---------|
  | `"OwnerName"` | The `OwnerName` field from the event |
  | `"$eventSourceId"` | The event source identifier |
  | `"$occurred"` | When the event was recorded |
  | `1` | A literal integer constant |
  | `"Amount"` | A named field from the event payload |

  ## Registering with Chronicle.Client

      {Chronicle.Client,
        ...
        read_models: [MyApp.ReadModels.Account]}

  ## Introspection

      MyApp.ReadModels.Account.__chronicle_read_model__(:id)
      MyApp.ReadModels.Account.__chronicle_read_model__(:from)
      MyApp.ReadModels.Account.__chronicle_read_model__(:join)
      MyApp.ReadModels.Account.__chronicle_read_model__(:removed_with)
      MyApp.ReadModels.Account.__chronicle_read_model__(:from_every)
      MyApp.ReadModels.Account.__chronicle_read_model__(:has_projection?)
  """

  @doc false
  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      Module.register_attribute(__MODULE__, :chronicle_projection_from, accumulate: true)
      Module.register_attribute(__MODULE__, :chronicle_projection_join, accumulate: true)
      Module.register_attribute(__MODULE__, :chronicle_projection_removed_with, accumulate: true)
      Module.register_attribute(__MODULE__, :chronicle_projection_from_every, accumulate: true)

      @chronicle_read_model_id Keyword.get(opts, :id, __MODULE__ |> Module.split() |> List.last())

      import Chronicle.ReadModel, only: [from: 2, join: 2, removed_with: 2, from_every: 1]

      @before_compile Chronicle.ReadModel
    end
  end

  @doc """
  Declares how an event maps onto this read model.

  See the `Chronicle.ReadModel` module documentation for full options.
  """
  defmacro from(event_module, opts) do
    quote do
      @chronicle_projection_from {unquote(event_module), unquote(opts)}
    end
  end

  @doc """
  Declares a join from a secondary event onto this read model.

  See the `Chronicle.ReadModel` module documentation for full options.
  """
  defmacro join(event_module, opts) do
    quote do
      @chronicle_projection_join {unquote(event_module), unquote(opts)}
    end
  end

  @doc """
  Declares that this model is removed when the given event occurs.

  See the `Chronicle.ReadModel` module documentation for full options.
  """
  defmacro removed_with(event_module, opts \\ []) do
    quote do
      @chronicle_projection_removed_with {unquote(event_module), unquote(opts)}
    end
  end

  @doc """
  Applies property mappings on every event, regardless of type.

  See the `Chronicle.ReadModel` module documentation for full options.
  """
  defmacro from_every(opts) do
    quote do
      @chronicle_projection_from_every unquote(opts)
    end
  end

  defmacro __before_compile__(_env) do
    quote do
      @doc false
      def __chronicle_read_model__(:id), do: @chronicle_read_model_id

      def __chronicle_read_model__(:from),
        do: @chronicle_projection_from |> Enum.reverse()

      def __chronicle_read_model__(:join),
        do: @chronicle_projection_join |> Enum.reverse()

      def __chronicle_read_model__(:removed_with),
        do: @chronicle_projection_removed_with |> Enum.reverse()

      def __chronicle_read_model__(:from_every),
        do: @chronicle_projection_from_every

      def __chronicle_read_model__(:has_projection?),
        do: not Enum.empty?(@chronicle_projection_from)
    end
  end
end