Skip to content
Merged
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
115 changes: 111 additions & 4 deletions lib/drops/operations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,16 @@ defmodule Drops.Operations do

@spec define(keyword()) :: Macro.t()
def define(opts) do
{ordered_extensions, set_opts} =
resolve_extension_dependencies(opts[:extensions], opts)

use_extensions =
Enum.map(opts[:extensions], &quote(do: use(unquote(&1), unquote(opts))))
|> Enum.reverse()
Enum.map(ordered_extensions, &quote(do: use(unquote(&1), unquote(set_opts))))

quote location: :keep do
import Drops.Operations

@opts unquote(opts)
@opts unquote(set_opts)

@unit_of_work Drops.Operations.UnitOfWork.new(__MODULE__, [])

Expand Down Expand Up @@ -149,7 +151,7 @@ defmodule Drops.Operations do
module = env.module

opts = Module.get_attribute(module, :opts)
enabled_extensions = Module.get_attribute(module, :enabled_extensions)
enabled_extensions = Enum.reverse(Module.get_attribute(module, :enabled_extensions))
custom_steps = Module.get_attribute(module, :steps, [])

extension_steps = Enum.map(enabled_extensions, fn extension -> extension.steps() end)
Expand Down Expand Up @@ -352,4 +354,109 @@ defmodule Drops.Operations do

Keyword.merge(parent_opts, new_opts) |> Keyword.put(:extensions, extensions)
end

@spec resolve_extension_dependencies([module()], keyword()) :: {[module()], keyword()}
defp resolve_extension_dependencies(extensions, opts) do
# Build dependency graph
all_extensions = collect_all_extensions(extensions, [])
ordered_extensions = topological_sort(all_extensions)

# Collect and merge default options from all extensions
extension_opts =
Enum.reduce(ordered_extensions, [], fn extension, acc ->
if function_exported?(extension, :default_opts, 1) do
extension_defaults = extension.default_opts(opts)
merge_opts(acc, extension_defaults)
else
acc
end
end)

# Merge extension options with user-provided options
merged_opts = merge_opts(extension_opts, opts)

{ordered_extensions, merged_opts}
end

@spec get_extension_dependencies(module()) :: [module()]
defp get_extension_dependencies(extension) when is_atom(extension) do
# Get the @depends_on module attribute from the extension
case extension.__info__(:attributes)[:depends_on] do
dependencies when is_list(dependencies) -> dependencies
_ -> []
end
end

# Handle AST nodes (during compilation) - they don't have dependencies yet
defp get_extension_dependencies(_extension), do: []

@spec collect_all_extensions([module()], [module()]) :: [module()]
defp collect_all_extensions([], acc), do: Enum.reverse(acc)

defp collect_all_extensions([extension | rest], acc) do
if extension in acc do
collect_all_extensions(rest, acc)
else
dependencies = get_extension_dependencies(extension)
acc_with_deps = collect_all_extensions(dependencies, [extension | acc])
collect_all_extensions(rest, acc_with_deps)
end
end

@spec topological_sort([module()]) :: [module()]
defp topological_sort(extensions) do
# Simple topological sort using Kahn's algorithm
# Build adjacency list and in-degree count
{graph, in_degree} = build_dependency_graph(extensions)

# Find nodes with no incoming edges
queue = Enum.filter(extensions, fn ext -> Map.get(in_degree, ext, 0) == 0 end)

sort_extensions(queue, graph, in_degree, [])
end

@spec build_dependency_graph([module()]) ::
{%{module() => [module()]}, %{module() => integer()}}
defp build_dependency_graph(extensions) do
graph = Map.new(extensions, fn ext -> {ext, []} end)
in_degree = Map.new(extensions, fn ext -> {ext, 0} end)

Enum.reduce(extensions, {graph, in_degree}, fn ext, {g, deg} ->
dependencies = get_extension_dependencies(ext)

Enum.reduce(dependencies, {g, deg}, fn dep, {graph_acc, degree_acc} ->
# dep -> ext (dependency points to dependent)
graph_acc = Map.update(graph_acc, dep, [ext], fn deps -> [ext | deps] end)
degree_acc = Map.update(degree_acc, ext, 1, fn count -> count + 1 end)
{graph_acc, degree_acc}
end)
end)
end

@spec sort_extensions([module()], %{module() => [module()]}, %{module() => integer()}, [
module()
]) :: [module()]
defp sort_extensions([], _graph, _in_degree, result), do: Enum.reverse(result)

defp sort_extensions([current | queue], graph, in_degree, result) do
# Add current to result
new_result = [current | result]

# For each dependent of current, decrease in-degree
dependents = Map.get(graph, current, [])

{new_queue, new_in_degree} =
Enum.reduce(dependents, {queue, in_degree}, fn dependent, {q, deg} ->
new_degree = Map.get(deg, dependent) - 1
new_deg = Map.put(deg, dependent, new_degree)

if new_degree == 0 do
{[dependent | q], new_deg}
else
{q, new_deg}
end
end)

sort_extensions(new_queue, graph, new_in_degree, new_result)
end
end
9 changes: 4 additions & 5 deletions lib/drops/operations/extension.ex
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,8 @@ defmodule Drops.Operations.Extension do
@opts unquote(opts)
def __opts__, do: @opts

Module.register_attribute(__MODULE__, :depends_on, persist: true)

@default_opts []
def default_opts(_opts), do: @default_opts
defoverridable default_opts: 1
Expand All @@ -224,18 +226,15 @@ defmodule Drops.Operations.Extension do
def steps, do: []
defoverridable steps: 0

@depends_on []

defmacro __using__(opts) do
extension = __MODULE__

if extension.enable?(opts) do
quote location: :keep do
@enabled_extensions unquote(extension)

merged_opts =
Keyword.merge(@opts, unquote(extension).default_opts(@opts))

@opts merged_opts

unquote(extension.using())
end
else
Expand Down
6 changes: 4 additions & 2 deletions lib/drops/operations/extensions/ecto.ex
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ defmodule Drops.Operations.Extensions.Ecto do
"""
use Drops.Operations.Extension

@depends_on [Drops.Operations.Extensions.Command, Drops.Operations.Extensions.Params]

@doc """
Creates a struct for changeset creation.

Expand Down Expand Up @@ -122,8 +124,8 @@ defmodule Drops.Operations.Extensions.Ecto do

@impl true
@spec default_opts(keyword()) :: keyword()
def default_opts(opts) do
[schema: [cast: true, atomize: opts[:type] == :form]]
def default_opts(_opts) do
[schema: [cast: true, atomize: true]]
end

@impl true
Expand Down
2 changes: 2 additions & 0 deletions lib/drops/operations/extensions/params.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ defmodule Drops.Operations.Extensions.Params do
"""
use Drops.Operations.Extension

@depends_on [Drops.Operations.Extensions.Command]

@impl true
@spec using() :: Macro.t()
def using do
Expand Down
9 changes: 7 additions & 2 deletions test/drops/operations/extension_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ defmodule Drops.Operations.ExtensionTest do
defmodule PrepareExtension do
use Drops.Operations.Extension

@depends_on [Drops.Operations.Extensions.Command]

@impl true
def using do
quote do
Expand Down Expand Up @@ -51,6 +53,8 @@ defmodule Drops.Operations.ExtensionTest do
defmodule StepExtension do
use Drops.Operations.Extension

@depends_on [Drops.Operations.Extensions.Command]

@impl true
def using do
quote do
Expand Down Expand Up @@ -215,12 +219,13 @@ defmodule Drops.Operations.ExtensionTest do
prepare_index = Enum.find_index(uow.step_order, &(&1 == :prepare))
before_index = Enum.find_index(uow.step_order, &(&1 == :log_before_prepare))

assert before_index == prepare_index - 1
# The log_before_prepare should be before prepare in the step order
assert before_index < prepare_index
assert uow.steps[:log_before_prepare] == {Test.StepOperation, :log_before_prepare}

# Verify log_after_prepare step is added after prepare
after_index = Enum.find_index(uow.step_order, &(&1 == :log_after_prepare))
assert after_index == prepare_index + 1
assert after_index > prepare_index
assert uow.steps[:log_after_prepare] == {Test.StepOperation, :log_after_prepare}
end

Expand Down