Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 10 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,11 @@ defmodule CreateAccount do
use Commanded.Command,
username: :string,
email: :string,
age: :integer
age: {:integer, default: 0},
aliases: {{:array, :string}} # Composite Type requires extra brace to avoid being interprated as opts
end

iex> CreateAccount.new()
iex> CreateAccount.new()
#Ecto.Changeset<action: nil, changes: %{}, errors: [], data: #CreateAccount<>, valid?: true>
```

Expand All @@ -54,7 +55,7 @@ defmodule CreateAccount do
end
end

iex> CreateAccount.new()
iex> CreateAccount.new()
#Ecto.Changeset<
action: nil,
changes: %{},
Expand All @@ -67,7 +68,7 @@ iex> CreateAccount.new()
valid?: false
>

iex> changeset = CreateAccount.new(username: "chris", email: "[email protected]", age: 5)
iex> changeset = CreateAccount.new(username: "chris", email: "[email protected]", age: 5)
#Ecto.Changeset<
action: nil,
changes: %{age: 5, email: "[email protected]", username: "chris"},
Expand Down Expand Up @@ -103,8 +104,7 @@ iex> AccountCreated.new(cmd)
%AccountCreated{
age: 5,
email: "[email protected]",
username: "chris",
version: 1
username: "chris"
}
```

Expand All @@ -124,8 +124,7 @@ iex> AccountCreated.new(cmd, date: NaiveDateTime.utc_now())
age: 5,
date: ~N[2019-07-25 08:03:15.372212],
email: "[email protected]",
username: "chris",
version: 1
username: "chris"
}
```

Expand All @@ -142,23 +141,18 @@ defmodule AccountCreated do
end

iex> event = AccountCreated.new(cmd)
%AccountCreated{age: 5, date: nil, username: "chris", version: 1}
%AccountCreated{age: 5, date: nil, username: "chris"}
```

#### Versioning

You may have noticed that we provide a default version of `1`.

You can change the version of an event at anytime.

After doing so, you should define an upcast instance that knows how to transform older events into the latest version.
You should define an upcast instance that knows how to transform older events into the latest version.

```elixir
defmodule AccountCreated do
use Commanded.Event,
version: 2,
from: CreateAccount,
with: [:date, :sex],
with: [:date, :sex, version: 2],
drop: [:email]

defimpl Commanded.Event.Upcaster do
Expand Down
72 changes: 54 additions & 18 deletions lib/commanded/command.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,29 @@ defmodule Commanded.Command do
@moduledoc ~S"""
Creates an `Ecto.Schema.embedded_schema` that supplies a command with all the validation power of the `Ecto.Changeset` data structure.

defmodule CreateAccount do
use Commanded.Command,
username: :string,
email: :string,
age: :integer

def handle_validate(changeset) do
changeset
|> validate_required([:username, :email, :age])
|> validate_format(:email, ~r/@/)
|> validate_number(:age, greater_than: 12)
end
defmodule CreateAccount do
use Commanded.Command,
username: :string,
email: :string,
age: :integer,
aliases: {{:array, :string}}

def handle_validate(changeset) do
changeset
|> Changeset.validate_required([:username, :email, :age])
|> Changeset.validate_format(:email, ~r/@/)
|> Changeset.validate_number(:age, greater_than: 12)
end
end

iex> CreateAccount.new(username: "chris", email: "[email protected]", age: 5, aliases: ["christopher", "kris"])
%CreateAccount{username: "chris", email: "[email protected]", age: 5, aliases: ["christopher", "kris"]}

iex> CreateAccount.validate(%{username: "chris", email: "[email protected]", age: 5, aliases: ["christopher", "kris"]})
#Ecto.Changeset<action: nil, changes: %{age: 5, aliases: ["christopher", "kris"], email: "[email protected]", username: "chris"}, errors: [age: {"must be greater than %{number}", [validation: :number, kind: :greater_than, number: 12]}], data: #CreateAccount<>, valid?: false>

iex> CreateAccount.new(username: "chris", email: "[email protected]", age: 5)
#Ecto.Changeset<action: nil, changes: %{age: 5, email: "[email protected]", username: "chris"}, errors: [age: {"must be greater than %{number}", [validation: :number, kind: :greater_than, number: 12]}], data: #CreateAccount<>, valid?: false>
iex> CreateAccount.validate(%{email: "emailson", age: 5})
#Ecto.Changeset<action: nil, changes: %{age: 5, email: "emailson"}, errors: [age: {"must be greater than %{number}", [validation: :number, kind: :greater_than, number: 12]}, email: {"has invalid format", [validation: :format]}, username: {"can't be blank", [validation: :required]}], data: #CreateAccount<>, valid?: false>
"""

@doc """
Expand All @@ -28,26 +35,50 @@ defmodule Commanded.Command do
defmacro __using__(schema) do
quote do
use Ecto.Schema
import Ecto.Changeset
import Ecto.Schema, only: [embedded_schema: 1, field: 2, field: 3]
import Commanded.Command

alias Ecto.Changeset
@behaviour Commanded.Command

@primary_key false
embedded_schema do
Enum.map(unquote(schema), fn
{name, {{_, _} = composite_type, opts}} -> field(name, field_type(composite_type), opts)
{name, {{_, _} = composite_type}} -> field(name, field_type(composite_type))
{name, {type, opts}} -> field(name, field_type(type), opts)
{name, type} -> field(name, field_type(type))
end)
end

def new(attrs \\ []) do
attrs
def new(), do: %__MODULE__{}
def new(source)

def new(%{__struct__: _} = source) do
source
|> Map.from_struct()
|> new()
end

def new(source) when is_list(source) do
source
|> Enum.into(%{})
|> new()
end

def new(source) when is_map(source) do
source |> create()
end

use ExConstructor, :create

def validate(command) when is_map(command) do
command
|> cast()
|> handle_validate()
end

def handle_validate(changeset), do: changeset
def handle_validate(%Ecto.Changeset{} = changeset), do: changeset

defoverridable handle_validate: 1

Expand All @@ -60,5 +91,10 @@ defmodule Commanded.Command do
end

def field_type(:binary_id), do: Ecto.UUID

def field_type(:array) do
raise "`:array` is not a valid Ecto.Type\nIf you are using a composite data type, wrap the type definition like this `{{:array, :string}}`"
end

def field_type(type), do: type
end
2 changes: 1 addition & 1 deletion lib/commanded/command_dispatch_validation.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ defmodule Commanded.CommandDispatchValidation do

def validate_and_dispatch(%Command{valid?: true} = command, opts) do
command
|> Command.apply_changes()
|> Ecto.Changeset.apply_changes()
|> __MODULE__.dispatch(opts)
end

Expand Down
66 changes: 66 additions & 0 deletions lib/commanded/command_validation_middleware.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
defmodule Commanded.Middleware.CommandValidation do
@moduledoc ~S"""
## Examples

defmodule CreateFakeAccount do
use Commanded.Command,
username: :string,
email: :string,
age: :integer,
aliases: {{:array, :string}}

def handle_validate(changeset) do
changeset
|> Changeset.validate_required([:username, :email, :age])
|> Changeset.validate_format(:email, ~r/@/)
|> Changeset.validate_number(:age, greater_than: 12)
end
end

Successfull command validation result will continue pipeline

iex> cmd = CreateFakeAccount.new(username: "chris", email: "[email protected]", age: "13")
iex> pipeline = %Commanded.Middleware.Pipeline{command: cmd}
iex> Commanded.Middleware.CommandValidation.before_dispatch(pipeline)
%Commanded.Middleware.Pipeline{halted: false, command: CreateFakeAccount.new(username: "chris", email: "[email protected]", age: 13)}

On error validation halt execution with changeset as response

iex> cmd = CreateFakeAccount.new(username: nil, email: "chrisexample.com", age: 5)
iex> pipeline = %Commanded.Middleware.Pipeline{command: cmd}
iex> response = Commanded.Middleware.CommandValidation.before_dispatch(pipeline)
iex> {:error, resp} = Map.get(response, :response)
iex> resp
#Ecto.Changeset<action: nil, changes: %{age: 5, email: "chrisexample.com"}, errors: [age: {"must be greater than %{number}", [validation: :number, kind: :greater_than, number: 12]}, email: {"has invalid format", [validation: :format]}, username: {"can't be blank", [validation: :required]}], data: #CreateFakeAccount<>, valid?: false>
"""

@behaviour Commanded.Middleware

alias Commanded.Middleware.Pipeline

def before_dispatch(%Pipeline{command: command} = pipeline) do
case validate_command(command) do
%{valid?: true} = changeset ->
%{pipeline | command: Ecto.Changeset.apply_changes(changeset)}

%{valid?: false} = changeset ->
pipeline
|> Map.put(:halted, true)
|> Map.put(:response, {:error, changeset})
end
end

def after_dispatch(%Pipeline{} = pipeline) do
pipeline
end

def after_failure(%Pipeline{} = pipeline) do
pipeline
end

defp validate_command(%{__struct__: command} = source) do
source
|> Map.from_struct()
|> command.validate()
end
end
47 changes: 24 additions & 23 deletions lib/commanded/event.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,30 @@ defmodule Commanded.Event do
* `from` - A struct to adapt the keys from.
* `with` - A list of keys to add to the event.
* `drop` - A list of keys to drop from the keys adapted from a struct.
* `version` - An optional version of the event. Defaults to `1`.

## Example

# This is for demonstration purposes only. You don't need to create a new event to version one.
defmodule AccountCreatedVersioned do
use Commanded.Event,
version: 2,
from: CreateAccount,
with: [:date, :sex],
drop: [:email],

defimpl Commanded.Event.Upcaster, for: AccountCreatedWithDroppedKeys do
def upcast(%{version: 1} = event, _metadata) do
AccountCreatedVersioned.new(event, sex: "maybe", version: 2)
end
This is for demonstration purposes only. You don't need to create a new event to version one.

defmodule AccountCreatedVersioned do
use Commanded.Event,
from: CreateAccount,
with: [:date, :sex, field_with_default_value: "default_value"],
drop: [:email]

def upcast(event, _metadata), do: event
end
defimpl Commanded.Event.Upcaster, for: AccountCreatedWithDroppedKeys do
def upcast(%AccountCreatedWithDroppedKeys{} = event, _metadata) do
AccountCreatedVersioned.new(event, sex: "maybe")
end

iex> changeset = CreateAccount.new(username: "chris", email: "[email protected]", age: 5)
iex> cmd = Ecto.Changeset.apply_changes(changeset)
iex> event = AccountCreatedWithDroppedKeys.new(cmd)
iex> Commanded.Event.Upcaster.upcast(event, %{})
%AccountCreatedVersioned{age: 5, date: nil, sex: "maybe", username: "chris", version: 2}
def upcast(event, _metadata), do: event
end
end

iex> cmd = CreateAccount.new(username: "chris", email: "[email protected]", age: 5)
iex> event = AccountCreatedWithDroppedKeys.new(cmd)
iex> Commanded.Event.Upcaster.upcast(event, %{})
%AccountCreatedVersioned{age: 5, date: nil, sex: "maybe", username: "chris", field_with_default_value: "default_value"}
"""

defmacro __using__(opts) do
Expand All @@ -52,15 +50,17 @@ defmodule Commanded.Event do
|> Map.keys()
end

version = Keyword.get(opts, :version, 1)
keys_to_drop = Keyword.get(opts, :drop, [])
explicit_keys = Keyword.get(opts, :with, [])

@derive Jason.Encoder
defstruct from
|> Kernel.++(explicit_keys)
|> Enum.reject(&Enum.member?(keys_to_drop, &1))
|> Kernel.++([{:version, version}])
|> Enum.uniq_by(fn
{key, _} -> key
key -> key
end)

def new(), do: %__MODULE__{}
def new(source, attrs \\ [])
Expand All @@ -78,7 +78,8 @@ defmodule Commanded.Event do
end

def new(source, attrs) when is_map(source) do
Map.merge(source, Enum.into(attrs, %{}))
source
|> Map.merge(Enum.into(attrs, %{}))
|> create()
end

Expand Down
Loading