TurtleAI / derive

An Event Sourcing and CQRS solution.
0 stars 1 forks source link

Filtering out no-ops returned by event handlers #23

Open rwillians opened 1 year ago

rwillians commented 1 year ago

Related to #16

In order to keep the code simpler and more readable, in some cases I personally prefer creating no-ops (or is it null-ops? -- in a reducer, it means producing an operation like an update that will do no side effect to the db). This avoids conditional creation of ops and overkill extractions with conditions and/or pattern matching.

The most common case, for me, is having a pattern match that might return null instead of an op. But there has been one case case where I used and updated that changed no fields (issue #16) -- this one was in Hatch, don't remember if there were any in TurtleOS.

Of course we could just wrap the returned list of ops with a function that filters no-ops (like reject null values or even filtering out Derive operations' structs based on some logic), therefore this isn't a blocker. But, I think this is something we could do on Derive's side the keep userland-code cleaner.

The sudo-code for what I'm suggesting would look like:

def foo (a, b, c) do
  apply(reducer_mod, :handle_event, [event])
  |> List.wrap() # in case a single op (not a list) is returned
  |> List.flatten() # in case there are nested lists, useful when you extract
                    # the conditional logic for generating subsets of events.
  |> Enum.reject(&no_op?/1)
  |> invoke_commit(reducer_mod)
end

defp no_op?(null), do: true
defp no_op?(%Update{fields: fields}) when map_size(fields) == 0, do: true
# ...
def no_op?(_), do: false

A use case example:

def handle_event(%Foo{} = event) do
  [
    maybe_delete_bar(event.baz)
  ]
end

defp maybe_delete_bar([]), do: nil
defp maybe_delete_bar([_ | _] = bar) do
  [bla: bar]
  |> Query.bars()
  |> delete()
end

Another one:

def handle_event(%Foo{} = event) do
  [
    maybe_update_bar(event)
  ]
end

defp maybe_update_bar(event) do
  transaction(fn repo ->
    current_bar = load_bar(repo, event.bar_id)
    new_bar = Map.merge(current_bar, %{a: event.bar_a, b: event.bar_b})
    changed_fields = diff(current_bar, new_bar)

    update({Bar, event.bar_id}, changed_fields) # there might be no fields changed and we actually have 
                                                # a use case like that. In fact, the query fails at
                                                # runtime if no fields were changed (that's issue 16).
  end)
end

It also makes it easier to do this:

def handle_event(%Foo{} = event) do
  [
    if (not is_nil(event.bar)) do
      insert(%Bar{id: event.bar_id, a: event.a, b: event.b})
    end
    # the `if` block will return null when the condition evaluates to false.
  ]
end

Edit: updated sudo-code for filtering out no-ops, now allows for this use case:

def handle_event(%Foo{} = event) do
  [
    maybe_delete_x(event.foo),
    maybe_create_y(event.bar),
    maybe_create_z(event.baz)
    # Each `maybe_*` function returns a list of 0 or more events.
    # That's why that `List.flatten/1` I added is important.
  ]
end
venkatd commented 1 year ago

@rwillians I like the idea of having as many ops as possible supporting no-ops where possible. As a bonus can remove unnecessary conditionals that have to check for empty lists.

venkatd commented 1 year ago

@rwillians maybe you could start with some ops that are adding boilerplate to your code and I can start with those