Current section
Files
Jump to
Current section
Files
lib/enum/transform.ex
defmodule Plymio.Enum.Transform do
@moduledoc ~S"""
Building, Composing and Applying `Transform Functions` for Enumerables.
A `transform function` normally takes one argument -- usually an
enumerable -- and applies a pipeline of `discrete transforms`, returning (again usually)
another enumerable.
Each `discrete transform` is usually the name of a `Stream` or `Enum` function (e.g. `:map`, `:filter`, `:group_by`, etc).
A `transform function` tries to be as *lazy* as possible, using (preferring) `Stream` over `Enum` and, if possible, returning a *lazy* enumerable.
A macro is provided (`defenumtransform/1`) to define a named function from a pipeline of discrete transforms.
> The companion module `Plymio.Enum.Tranform.Dictionary` supports a map-like dictionary of named transforms. It also supports the **composition** of higher level transforms from transforms in the dictionary, stand alone transforms, and/or new pipelines. **Composed** transforms can be saved in the dictionary.
## Building a Transform Function
`build/1` builds a `transform function` from a pipeline of `discrete transforms`.
Each `discrete transform` is (usually) the name of a function
supported by `Stream` and/or `Enum` (e.g. `:filter`, `:map`, `:reject`,
`:group_by`, etc), together with the arguments taken by the function.
Each `discrete transform` in the pipeline results in a call to
`Stream` (or `Enum` when the transform is `Enum`-only e.g.
`Enum.group_by/2`). The calls to `Stream` / `Enum` are then
[**composed**](https://en.wikipedia.org/wiki/Function_composition_(computer_science)) into a single function.
In this example, all the discrete transforms can be lazily applied
(i.e. are supported by `Stream`) so a `Stream` is returned. (The
stream can be realised using `Enum.to_list/1`):
iex> fun = [filter: fn v -> is_number(v) end,
...> filter: fn v -> v > 0 end,
...> map: fn v -> v * v end,
...> map: fn v -> v + 42 end,
...> reject: fn v -> v < 45 end,
...> reject: fn v -> v > 50 end]
...> |> build
...> stream = [-1, make_ref(), 1, :atom, 2, "string", 3, &(&1)] |> fun.()
...> stream |> Enum.to_list
[46]
In this example, the last transformation is `Enum.group_by/2` which
always returns a `Map`.
iex> fun = [filter: fn {_k,v} -> is_number(v) end,
...> map: fn {k,v} -> {k,v*v} end,
...> group_by: fn {k,_v} -> k |> to_string end]
...> |> build
...> [a: 1, b: 2, c: 3, d: :atom] |> fun.()
%{"a" => [a: 1], "b" => [b: 4], "c" => [c: 9]}
Arguments to each discrete transforms must be given is the expected
order. This example includes a final `Enum.reduce/2` with zero as
the initial value of the accumulator.
iex> fun = [filter: fn {_k,v} -> is_number(v) end,
...> map: fn {k,v} -> {k,v*v} end,
...> group_by: fn {k,_v} -> k |> to_string end,
...> reduce: [0, fn {_k,v},s -> (Keyword.values(v) |> Enum.sum) + s end]]
...> |> build
...> [a: 1, b: 2, c: 3, d: :atom] |> fun.()
14
## Composing Prebuilt Transform Functions
Prebuilt `transform functions` can be **composed** just by including them in the pipeline of discrete transforms passed to `build/1`:
In this example a new `transform function` is **composed** from 3 separate, prebuilt `transform functions` and a final subpipeline (`[map: fn v - 4 end]`) (which is built recursively).
iex> filter_fun = [filter: [fn v -> is_number(v) end, fn v -> v > 0 end]]
...> |> build
...> mapper_fun = [map: [fn v -> v * v end, fn v -> v + 42 end]]
...> |> build
...> reject_fun = [reject: [fn v -> v < 45 end, fn v -> v > 50 end]]
...> |> build
...> fun = [filter_fun, mapper_fun, reject_fun, [map: fn v -> v - 4 end]] |> build
...> stream = [-1, make_ref(), 1, :atom, 2, "string", 3, &(&1)] |> fun.()
...> stream |> Enum.to_list
[42]
## Using Multiple Functions in Discrete Transforms
Usually multiple functions can be used in each discrete transform.
For example the first example above can be rewritten with a list of functions for each discrete transform:
iex> fun = [filter: [fn v -> is_number(v) end, fn v -> v > 0 end],
...> map: [fn v -> v * v end, fn v -> v + 42 end],
...> reject: [fn v -> v < 45 end, fn v -> v > 50 end]]
...> |> build
...> stream = [-1, make_ref(), 1, :atom, 2, "string", 3, &(&1)] |> fun.()
...> stream |> Enum.to_list
[46]
Discrete transforms with multiple arguments (e.g. `Stream.map_every/3`) can also use multiple functions. In this example every other element in the enumerable is mapped. Note the two functions in a list.
> Note: `Stream.map_every/3` *always* maps the zeroth element of the enumerable.
iex> fun = [map_every: [2, [fn v -> v * v end, fn v -> v + 42 end]]]
...> |> build
...> stream = [1, 2, 3, 4, 5] |> fun.()
...> stream |> Enum.to_list
[43, 2, 51, 4, 67]
When multiple functions are given, they have to be "combined" according to their purpose (e.g. `filter`):
### Combining Multiple Functions: filter
Multiple `filter`-type functions `AND` together the results of applying each one to the value being tested (using `Enum.all?/2`) e.g.
iex> fun = fn value ->
...> [fn v -> is_number(v) end, fn v -> v > 0 end]
...> |> Enum.all?(fn f -> f.(value) end)
...> end
...> [-1, make_ref(), 1, :atom, 2, "string", 3, &(&1)] |> Enum.filter(fun)
[1, 2, 3]
### Combining Multiple Functions: reject
Multiple `reject`-type functions are `OR`-ed together using `Enum.any?/2` e.g.
iex> fun = fn value ->
...> [fn v -> v < 45 end, fn v -> v > 50 end]
...> |> Enum.any?(fn f -> f.(value) end)
...> end
...> [43, 46, 51] |> Enum.reject(fun)
[46]
### Combining Multiple Functions: map
Multiple `map`-type functions are combined using `Enum.reduce/2` e.g.
iex> fun = fn value ->
...> [fn v -> v * v end, fn v -> v + 42 end]
...> |> Enum.reduce(value, fn f,v -> f.(v) end)
...> end
...> [1, 2, 3] |> Enum.map(fun)
[43, 46, 51]
### Combining Multiple Functions: reduce
`reduce` functions are normally arity 2 taking the current value from the enumerable, togther with the accumulator.
This constraint is relaxed when multiple functions are used and each function can be arity 1 or 2. An arity 1 is passed just the result of the previous function, no accumulator (just like a map). The code to combine multiple functions looks something like this.
> Note for each value of the enumerable, each function is passed the *same* accumulator.
iex> fun1 = fn v, s -> v + s end
...> fun2 = fn v -> v - 42 end
...> fun3 = fn v, s -> v * s end
...> fun = fn value, acc ->
...> [fun1, fun2, fun3]
...> |> Enum.reduce(value, fn
...> f,v when is_function(f, 2) -> f.(v,acc)
...> f,v when is_function(f, 1) -> f.(v)
...> end)
...> end
...> [1, 2, 3] |> Enum.reduce(7, fun)
4375094500
## Discrete Transform Forms
In the examples above the pipeline of discrete transforms was a `Keyword` where the keys were `Stream` and/or `Enum` functions, and the values their additional arguments (without the enumerable).
More generally the definition of each discrete transformation can have a number of forms.
> Its worth stressing that the discrete transform pipeline is *always* a List but not always a Keyword.
### Discrete Transform Forms: {name,args} when is_atom(name)
This is the form used so far. The `name` (an `Atom`) *must* be a function of `Stream` or `Enum`.
iex> fun = [filter: fn {_k,v} -> is_number(v) end]
...> |> build
...> [a: 1, b: 2, c: 3, d: :atom] |> fun.() |> Enum.to_list
[a: 1, b: 2, c: 3]
When the discrete transform doesn't take any other arguments other than the enumerable, the args in the 2tuple can be nil or an empty list.
iex> fun = [count: nil]
...> |> build
...> [a: 1, b: 2, c: 3, d: :atom] |> fun.()
4
### Discrete Transform Forms: name when is_atom(name)
When `name` is an `Atom`, it *must* be a function of `Stream` or `Enum` that *only* takes an enumerable; no other arguments.
iex> fun = [:count]
...> |> build
...> [a: 1, b: 2, c: 3, d: :atom] |> fun.()
4
Using this form means the other discrete transforms must be e.g. {name,args} else the Elixir compiler will complain since the pipeline is no longer a `Keyword`:
iex> fun = [{:map, fn {_k,v} -> v*v end}, :sum]
...> |> build
...> [a: 1, b: 2, c: 3] |> fun.()
14
### Discrete Transform Forms: {mod,fun_name,args}
The general purpose MFA (`module,function,arguments`) form used with `Kernel.apply/3` is supported. The enumerable is prepended to the arguments (`[enum | arguments]`).
This example uses the MFA form of `[map: &(&1)]`
iex> fun = [{Stream, :map, [&(&1)]}] |> build
iex> [a: 1, b: 2, c: 3, d: :atom] |> fun.() |> Enum.to_list
[a: 1, b: 2, c: 3, d: :atom]
However, an MFA can call *any* module and function, not just `Stream` or `Enum` ones. For example `List.duplicate/2` is used to create an enumerable to feed the map squaring each value, with a final `:sum` to add up all the values.
iex> fun = [{List, :duplicate, [3]}, {:map, fn v -> v*v end}, :sum]
...> |> build
...> 42 |> fun.()
5292
Here is another example combining `Stream` / `Enum` 2tuples with an
MFA. Note though, the result of the `filter`, `map` and `reject`
discrete transforms will be a `Stream`. `List` functions require a
list as input, hence the `:to_list` in the transform pipeline just
before the `insert_at`.
> Since the pipeline definition is no longer a `Keyword`, it must use the explicit 2tuple syntax.
iex> fun = [{:filter, [fn {_k,v} -> is_number(v) end, fn {_k,v} -> v > 0 end]},
...> {:map, [fn {k,v} -> {k, v * v} end, fn {k,v} -> {k, v + 42} end]},
...> {:reject, [fn {_k,v} -> v < 45 end, fn {_k,v} -> v > 50 end]},
...> :to_list,
...> {List, :insert_at, [2, {:e, "five"}]}]
...> |> build
...> [a: 1, b: 2, c: 3, d: :atom] |> fun.() |> Enum.to_list
[b: 46, e: "five"]
### Discrete Transform Forms: fun when is_function(fun)
The transform can also be a function and is passed the
result of the previous transforms:
iex> fun = [{:filter, [fn {_k,v} -> is_number(v) end, fn {_k,v} -> v > 0 end]},
...> {:map, [fn {k,v} -> {k, v * v} end, fn {k,v} -> {k, v + 42} end]},
...> # a transform function
...> fn enum -> enum |> Stream.map(fn {k,v} -> {k |> to_string, v} end) end,
...> {:into, %{}}]
...> |> build
...> [a: 1, b: 2, c: 3, d: :atom] |> fun.()
%{"a" => 43, "b" => 46, "c" => 51}
## Applying a Transform Function
`transform/2` is a convenience function taking an enumerable and *either* a `transform function` *or* pipeline of discrete transforms.
If a pipeline is given, the `transform function` is built
*on-the-fly* (using `build/1`), used to transform the enumerable and then discarded. If the transform
is expected to be used many times, it is more efficient to build the
`transform function` first.
Here the `transform function` is built *on-the-fly*
iex> pipeline = [{:map, fn {_k,v} -> v*v end}, :sum]
...> [a: 1, b: 2, c: 3] |> transform(pipeline)
14
Here the `transform function` is prebuilt and passed to `transform/2`
iex> fun = [{:map, fn {_k,v} -> v*v end}, :sum] |> build
...> [a: 1, b: 2, c: 3] |> transform(fun)
14
Frequently the result of `transform/2` is *lazy*
iex> fun = [{:map, fn {_k,v} -> v*v end}] |> build
...> result = [a: 1, b: 2, c: 3] |> transform(fun)
...> match?(%Stream{}, result)
true
> `Plymio.Enum.Transform.Dictionary` provides support for easily applying prebuilt transforms.
## Realising the Result of a Transformed Function
`realise/2` is another convenience function taking an enumerable and *either* a `transform function` *or* pipeline of discrete transforms.
`transform/2` is used to apply the transformation, and if the result is a *lazy* enumerable, it is *realised* (using `Enum.to_list/1`).
Here the `transform function` is prebuilt and passed to `realise/2`. Note the enumerable is *lazy*.
iex> fun = [{:map, fn {_k,v} -> v*v end}, :sum] |> build
...> [a: 1, b: 2, c: 3] |> Stream.map(&(&1)) |> realise(fun)
14
## Defining a Named Transform Function
Although the focus of this module is to create `transform functions` at run time, it is possible to define a named `transform function`, using a pipeline of discrete transforms.
The `defenumtransform/1` macro is quite simple, its takes the name of the function together with the pipeline as the argument:
defenumtransform named_transform1([{:map, fn {_k,v} -> v*v end}, :sum])
The named function can be used as expected:
iex> [a: 1, b: 2, c: 3] |> Stream.map(&(&1)) |> realise(&named_transform1/1)
14
## Notes
### `each`
`Stream.each/2` is preferred but it returns the **original** enumerable whereas `Enum.each/2` returns `:ok`.
iex> fun = [each: fn {_k,v} -> v*v end] |> build
...> [a: 1, b: 2, c: 3] |> realise(fun)
[a: 1, b: 2, c: 3]
Here the MFA form of a discrete transform is used to explicitly call `Enum.each/2`:
iex> fun = [{Enum, :each, [fn {_k,v} -> v*v end]}] |> build
...> [a: 1, b: 2, c: 3] |> realise(fun)
:ok
### `into`
`Enum.into/2` is preferred over `Stream.into/2` as the latter "loses" the type of the collectable when it is realised:
iex> fun = [into: %{}] |> build
...> [a: 1, b: 2, c: 3] |> realise(fun)
%{a: 1, b: 2, c: 3}
Here the MFA form of a discrete transform is used to explicitly call `Stream.into/2`:
iex> fun = [{Stream, :into, [%{}]}] |> build
...> [a: 1, b: 2, c: 3] |> realise(fun)
[a: 1, b: 2, c: 3]
"""
alias Plymio.Enum.Utils, as: PEU
require Logger
@type enum :: Enumerable.t
@type discrete_function_name :: atom
@type discrete_module :: atom
@type discrete_function :: (any -> any)
@type discrete_module_function_name_tuple :: {discrete_module, discrete_function_name}
@type discrete_args :: nil | any | [any]
@type discrete_transform ::
discrete_function_name |
{discrete_function_name, discrete_args} |
{discrete_module, discrete_function_name, discrete_args} |
{discrete_module_function_name_tuple, discrete_args} |
discrete_function |
{discrete_function, discrete_args}
@type transform_pipeline :: [discrete_transform]
@type transform_function :: nil | (any -> any)
@plymio_transform_enum_fva Enum.__info__(:functions)
@plymio_transform_stream_fva Stream.__info__(:functions)
@plymio_transform_enum_keys @plymio_transform_enum_fva |> Keyword.keys |> Enum.uniq
@plymio_transform_stream_keys @plymio_transform_stream_fva |> Keyword.keys |> Enum.uniq
@plymio_transform_enum_only_keys @plymio_transform_enum_keys -- @plymio_transform_stream_keys
# edit_verbs where should use ENUM
@plymio_transform_enum_preferred_keys [:into] ++ @plymio_transform_enum_only_keys
|> Enum.uniq
# edit_verbs where should use STREAM
@plymio_transform_stream_preferred_keys @plymio_transform_stream_keys -- [:into, :__struct__]
@plymio_transform_enum_preferred_tuples @plymio_transform_enum_preferred_keys
|> Enum.map(fn key -> {key, {Enum,key}} end)
@plymio_transform_stream_preferred_tuples @plymio_transform_stream_preferred_keys
|> Enum.map(fn key -> {key, {Stream,key}} end)
# remember: first (Enum) wins
@plymio_transform_preferred_map @plymio_transform_enum_preferred_tuples ++ @plymio_transform_stream_preferred_tuples
|> Enum.into(%{})
@plymio_transform_preferred_keys @plymio_transform_preferred_map |> Map.keys |> Enum.sort
# In the below the part after @plymio_transform_transform_opts_ follow this convention:
# n = arity (- 1 i.e after the enum)
# v = value
# m = mapper fun (value -> value)
# r = reduce fun (value, acc -> acc)
# f = filter fun (multiple filters are AND-ed)
# g = not filter fun (NOT AND)
# j = reject fun (miltiple rejects are OR-ed)
# k = not reject fun (OR)
# c = compare (value,1, value2 -> boolean)
@plymio_transform_transform_opts_0 [
:"all?",
:"any?",
:count,
:cycle,
:dedup,
:"empty?",
:join,
:interval,
:max,
:min,
:min_max,
:random,
:reverse,
:run,
:shuffle,
:sort,
:sum,
:to_list,
:uniq,
:unzip,
:with_index,
:zip,
]
@plymio_transform_transform_opts_1_f [
:"all?",
:"any?",
:count,
:filter,
:find,
:find_index,
:find_value,
:split_with,
:take_while,
]
@plymio_transform_transform_opts_1_g [
]
@plymio_transform_transform_opts_1_j [
:drop_while,
:reject,
:split_while,
]
@plymio_transform_transform_opts_1_k [
]
@plymio_transform_transform_opts_1_m [
:chunk_by,
:dedup_by,
:each,
:flat_map,
:group_by,
:iterate,
:map,
:map_join,
:max_by,
:min_by,
:min_max_by,
:repeatedly,
:sort_by,
:uniq_by,
]
@plymio_transform_transform_opts_1_r [
:reduce,
:scan,
]
@plymio_transform_transform_opts_1_c [
:sort
]
# all funs that take one args that is a fun or list of funs
@plymio_transform_transform_opts_1_fun @plymio_transform_transform_opts_1_f ++
@plymio_transform_transform_opts_1_g ++
@plymio_transform_transform_opts_1_j ++
@plymio_transform_transform_opts_1_k ++
@plymio_transform_transform_opts_1_m ++
@plymio_transform_transform_opts_1_r
@plymio_transform_transform_opts_1_v [
:at,
:with_index,
:chunk,
:concat,
:drop,
:drop_every,
:fetch,
:"fetch!",
:intersperse,
:into,
:join,
:member?,
:reverse,
:slice,
:split,
:take,
:take_every,
:take_random,
:timer,
:with_index,
:zip,
]
# do *not* add anything else without reviewing
# normalise_edit_opts
@plymio_transform_transform_opts_2_v_f [
:find,
:find_value,
:unfold,
]
@plymio_transform_transform_opts_2_v_m [
:into,
:map_every,
:map_join,
]
@plymio_transform_transform_opts_2_v_r [
:flat_map_reduce,
:map_reduce,
:reduce,
:reduce_while,
:scan,
:transform,
]
@plymio_transform_transform_opts_2_f_m [
:filter_map,
]
@plymio_transform_transform_opts_2_m_c [
:sort_by,
]
@plymio_transform_transform_opts_2_v_v [
:at,
:chunk,
:slice,
:reverse_slice,
]
@plymio_transform_transform_opts_3_v_v_v [
:chunk,
]
@plymio_transform_transform_opts_3_m_r_m [
:transform,
]
# these are the function that have arities 2 or 3 e.g. reduce
# where multiple funs can be ambguous
@plymio_transform_transform_opts_2_v_fjmr_OR_1_fjmr [
[@plymio_transform_transform_opts_1_f, @plymio_transform_transform_opts_2_v_f],
[@plymio_transform_transform_opts_1_m, @plymio_transform_transform_opts_2_v_m],
[@plymio_transform_transform_opts_1_r, @plymio_transform_transform_opts_2_v_r]
]
|> Enum.reduce([],
fn fun_lists, s ->
# find the intersection of the fun_lists
funs = fun_lists
|> Enum.map(&MapSet.new/1)
|> Enum.reduce(&MapSet.intersection/2)
|> MapSet.to_list
s ++ funs
end)
|> List.flatten
defp normalise_edit_funs(funs) do
funs |> List.wrap |> List.flatten |> Enum.reject(&is_nil/1)
end
defp reduce_edit_funs(edit_verb, edit_funs)
defp reduce_edit_funs(:map, edit_funs) do
fn value ->
edit_funs |> Enum.reduce(value, fn fun, s -> fun.(s) end)
end
end
defp reduce_edit_funs(:reduce, edit_funs) do
fn value, s ->
edit_funs
|> Enum.reduce(value,
fn
fun, value when is_function(fun, 2) -> fun.(value, s)
fun, value when is_function(fun, 1) -> fun.(value)
end)
end
end
defp reduce_edit_funs(edit_verb, edit_funs)
when edit_verb in [:and, :filter] do
fn value ->
edit_funs |> Enum.all?(fn fun -> fun.(value) end)
end
end
defp reduce_edit_funs(edit_verb, edit_funs)
when edit_verb in [:or, :reject] do
fn value ->
edit_funs |> Enum.any?(fn fun -> fun.(value) end)
end
end
defp resolve_edit_fun(edit_verb, edit_funs)
defp resolve_edit_fun(_edit_verb, edit_fun) when is_function(edit_fun) do
edit_fun
end
defp resolve_edit_fun(edit_verb, [edit_fun])
when is_function(edit_fun) and (not is_tuple(edit_verb)) do
edit_fun
end
defp resolve_edit_fun(edit_verb, edit_funs) when is_list(edit_funs) do
edit_funs
|> normalise_edit_funs
|> case do
# simple fun
[edit_fun] -> edit_fun
# multiple funs => reduce
edit_funs -> reduce_edit_funs(edit_verb, edit_funs)
end
end
# header
defp resolve_edit_args(edit_verb, edit_opts)
defp resolve_edit_args(edit_verb, [])
when edit_verb in @plymio_transform_transform_opts_0 do
[]
end
# two funs: mapper and sorter (compare)
# BEFORE 1_M (to catch sort_by)
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_2_m_c
and is_list(edit_opts) and length(edit_opts) == 2 do
mapper_fun = resolve_edit_fun(:mapper, edit_opts |> List.first)
sorter_fun = edit_opts |> List.last
[mapper_fun, sorter_fun]
end
# one or more filter fun(s)
# filter funs are effectively AND-ed
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_1_f
and is_list(edit_opts) and length(edit_opts) == 1 do
resolve_edit_fun(:filter, edit_opts)
|> List.wrap
end
# one or more reject fun(s)
# reject funs are effectively OR-ed
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_1_j
and is_list(edit_opts) and length(edit_opts) == 1 do
resolve_edit_fun(:reject, edit_opts)
|> List.wrap
end
# one or more mapper fun(s)
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_1_m
and is_list(edit_opts) and length(edit_opts) == 1 do
resolve_edit_fun(:map, edit_opts)
|> List.wrap
end
# one or more reduce fun(s)
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_1_r
and is_list(edit_opts) and length(edit_opts) == 1 do
resolve_edit_fun(:reduce, edit_opts)
|> List.wrap
end
# a compare fun
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_1_c
and is_list(edit_opts) and length(edit_opts) == 1 do
edit_opts
end
# just a value
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_1_v
and is_list(edit_opts) and length(edit_opts) == 1 do
edit_opts
end
# two funs: filter and mapper
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_2_f_m
and is_list(edit_opts) and length(edit_opts) == 2 do
filter_fun = resolve_edit_fun(:filter, edit_opts |> List.first)
mapper_fun = resolve_edit_fun(:map, edit_opts |> List.last)
[filter_fun, mapper_fun]
end
# value + filter fun(s)
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_2_v_f
and is_list(edit_opts) and length(edit_opts) == 2 do
edit_fun = resolve_edit_fun(:filter, edit_opts |> List.last)
[edit_opts |> List.first, edit_fun]
end
# value + mapper fun(s)
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_2_v_m
and is_list(edit_opts) and length(edit_opts) == 2 do
edit_fun = resolve_edit_fun(:map, edit_opts |> List.last)
[edit_opts |> List.first, edit_fun]
end
# value + reducer fun(s)
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_2_v_r
and is_list(edit_opts) and length(edit_opts) == 2 do
edit_fun = resolve_edit_fun(:reduce, edit_opts |> List.last)
[edit_opts |> List.first, edit_fun]
end
# two values
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_2_v_v
and is_list(edit_opts) and length(edit_opts) == 2 do
edit_opts
end
# three values
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_3_v_v_v
and is_list(edit_opts) and length(edit_opts) == 3 do
edit_opts
end
# mapper, reducer, mapper
defp resolve_edit_args(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_3_m_r_m
and is_list(edit_opts) and length(edit_opts) == 3 do
mapper1_fun = resolve_edit_fun(:map, edit_opts |> Enum.at(0))
reducer_fun = resolve_edit_fun(:map, edit_opts |> Enum.at(1))
mapper2_fun = resolve_edit_fun(:map, edit_opts |> Enum.at(2))
[mapper1_fun, reducer_fun, mapper2_fun]
end
defp normalise_edit_opts(edit_verb, edit_opts)
# into: [] => into: [[]] i.e. list of args in a list with an empty list
defp normalise_edit_opts(edit_verb, [])
when edit_verb in [:into] do
[[]]
end
defp normalise_edit_opts(edit_verb, nil)
when edit_verb in @plymio_transform_transform_opts_0 do
[]
end
defp normalise_edit_opts(edit_verb, edit_opts)
when edit_verb in [:into] do
edit_opts = edit_opts |> List.wrap
case edit_opts |> length do
# empty? => into a list
0 -> [[]]
# collectable
1 ->
cond do
is_map(edit_opts |> List.first) -> edit_opts
Keyword.keyword?(edit_opts) -> [edit_opts]
true -> edit_opts
end
# ambiguous
2 ->
case edit_opts |> List.last |> is_function do
# collectable + transform
true ->
edit_opts
# collectable
_ ->
edit_opts
end
# collectable
_ ->
[edit_opts]
end
end
defp normalise_edit_opts(edit_verb, edit_opts)
when edit_verb in [:sort_by] do
edit_opts |> List.wrap
end
defp normalise_edit_opts(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_2_v_fjmr_OR_1_fjmr do
edit_opts = edit_opts |> List.wrap
case edit_opts |> length do
# ambiguous
2 ->
# this code can *not* disambiguate a 2list where the value
# is a fun and the fjm is just one fun.
# to cater, make the fjm fun a list of one fun.
cond do
# is a list of funs? => no value
Enum.all?(edit_opts, fn fun -> fun |> is_function end) ->
[edit_opts]
# value + one or more funs
true ->
edit_opts
end
# just funs
_ ->
[edit_opts]
end
end
defp normalise_edit_opts(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_2_v_f do
edit_opts = edit_opts |> List.wrap
case edit_opts |> length do
# ambiguous
2 ->
# this code can *not* disambiguate a 2list where the default
# is a fun and the filter is just one fun.
# to cater, make the filter fun a list of one fun.
cond do
# is a list of funs? => no default
Enum.all?(edit_opts, fn fun -> fun |> is_function end) ->
[edit_opts]
# default + one or more filter funs
true ->
edit_opts
end
# just (filter) funs
_ ->
[edit_opts]
end
end
# could be a list of funs - need to make a list with one entry
defp normalise_edit_opts(edit_verb, edit_opts)
when edit_verb in [:concat]
and is_list(edit_opts) do
cond do
# is a list of enums? create a new list so that the build_apply_fun works
Enum.all?(edit_opts, fn enum -> enum |> PEU.enum? end) ->
[edit_opts]
# nope - must be a left, right signature. make a list of a list of (one) enums
# so apply build works
PEU.enum?(edit_opts) ->
[[edit_opts]]
# no default
end
end
# one or more funs of type filter, reject, mapper or reduce
defp normalise_edit_opts(edit_verb, edit_opts)
when edit_verb in @plymio_transform_transform_opts_1_fun and is_list(edit_opts) do
# force a wrap; will be flattened later
[edit_opts |> List.wrap]
end
# default
defp normalise_edit_opts(_edit_verb, edit_opts) do
edit_opts |> List.wrap
end
defp resolve_edit_tuple(edit_tuple)
defp resolve_edit_tuple(edit_verb) when is_atom(edit_verb) do
@plymio_transform_preferred_map |> Map.fetch!(edit_verb)
end
defp resolve_edit_tuple({edit_mod, edit_fun})
when is_atom(edit_mod) and is_atom(edit_fun) do
{edit_mod, edit_fun}
end
# header
defp build_discrete_fun(edit_tuple, edit_args, edit_opts)
# list of enumerables
defp build_discrete_fun({edit_mod, edit_verb}, edit_args, edit_opts)
when edit_verb in [:concat] and is_list(edit_opts) and length(edit_opts) == 1 do
fn enum ->
args = edit_args
|> List.first
|> List.insert_at(0, enum)
apply(edit_mod, edit_verb, [args])
end
end
defp build_discrete_fun({edit_mod, edit_verb}, edit_args, _edit_opts)
when edit_verb in @plymio_transform_preferred_keys do
fn enum -> apply(edit_mod, edit_verb, [enum | edit_args]) end
# fn enum ->
# apply(edit_mod, edit_verb, [enum | edit_args])
# end
end
defp build_discrete_fun({edit_mod, edit_verb}, edit_args, _edit_opts) do
fn enum -> apply(edit_mod, edit_verb, [enum | edit_args]) end
end
defp build_discrete_fun(fun, edit_args, _edit_opts)
when is_function(fun) do
fn enum -> apply(fun, [enum | edit_args]) end
end
# header
defp build_reduce_fun(edit_tuple, edit_opts)
defp build_reduce_fun({edit_mod, edit_verb}, edit_opts)
when edit_mod in [Enum, Stream] do
edit_opts = normalise_edit_opts(edit_verb, edit_opts)
edit_args = resolve_edit_args(edit_verb, edit_opts)
build_discrete_fun({edit_mod, edit_verb}, edit_args, edit_opts)
end
defp build_reduce_fun({edit_mod, edit_verb}, edit_opts) do
edit_args = edit_opts |> List.wrap
build_discrete_fun({edit_mod, edit_verb}, edit_args, edit_opts)
end
defp build_reduce_fun(fun, edit_opts)
when is_function(fun) do
edit_args = edit_opts |> List.wrap
build_discrete_fun(fun, edit_args, edit_opts)
end
@doc ~S"""
Builds a transform function when given a discrete transform pipeline.
See examples above.
"""
@spec build(transform_pipeline) :: transform_function
def build(opts) do
# build the fun for each edit
# each edit_fun must take a enum and return an enum (maybe emtpy)
edit_funs = opts
|> List.wrap
# regularise the opts
|> Enum.map(fn
# fun
fun when is_function(fun) ->
{fun, []}
# fun + opts
{fun, edit_opts} when is_function(fun) ->
{fun, edit_opts}
# mfa
{edit_mod, edit_fun, edit_args} ->
{{edit_mod, edit_fun}, edit_args}
{edit_tuple, edit_opts} ->
{edit_tuple |> resolve_edit_tuple, edit_opts}
# just the verb i.e no other args other than enum
edit_verb when is_atom(edit_verb) ->
{edit_verb |> resolve_edit_tuple, []}
# another (sub)pipeline?
pipeline when is_list(pipeline) ->
# recurse to build the subpipeline
fun = pipeline |> build
{fun, []}
end)
# build the edit_fun
|> Enum.map(fn {edit_tuple, edit_opts} ->
build_reduce_fun(edit_tuple, edit_opts)
end)
fn enum ->
edit_funs
|> Enum.reduce(enum, fn fun, s -> fun.(s) end)
end
end
@doc ~S"""
`transform/2` is a convenience function whose arguments
are an enumerable together with a `transform_function` or
`transform_pipeline`.
If a `transform_pipeline` is given, a `transform_function` is built
using `build/1`. (This is not optimal if the same call will be made repeatedly.)
The `transform_function` (either passed as an argument or built on the fly) is
then applied to the enumerable.
The result is often a lazy enumerable (e.g. `Stream`), but not always.
## Examples
> Note, in this example the final discrete transform `group_by` produces a `Map`.
Here a `transform_pipeline` is passed forcing the `transform_function` to be built on the fly:
iex> [a: 1, b: 2, c: 3, d: :atom]
...> |> transform(
...> filter: fn {_k,v} -> is_number(v) end,
...> map: fn {k,v} -> {k,v*v} end,
...> group_by: fn {k,_v} -> k |> to_string end)
%{"a" => [a: 1], "b" => [b: 4], "c" => [c: 9]}
In this example, the apply is passed a pre-built `transform_function`:
iex> fun = [filter: fn {_k,v} -> is_number(v) end,
...> map: fn {k,v} -> {k,v*v} end,
...> group_by: fn {k,_v} -> k |> to_string end]
...> |> build
iex> [a: 1, b: 2, c: 3, d: :atom] |> transform(fun)
%{"a" => [a: 1], "b" => [b: 4], "c" => [c: 9]}
"""
@spec transform(enum, transform_function | transform_pipeline) :: any
def transform(enum, opts \\ [])
def transform(enum, []) do
enum
end
def transform(enum, fun) when is_function(fun) do
fun.(enum)
end
def transform(enum, opts) when is_list(opts) do
edit_fun = opts
|> build
edit_fun.(enum)
end
@doc ~S"""
`transform/2` is another convenience function whose arguments
are an enumerable together with a `transform_function` or
`transform_pipeline`.
`transform/2` is used to generate the result but, if the result is a lazy enum,erable (e.g. `Stream`), it is realised recursively.
## Examples
Here a `transform_pipeline` is passed forcing the
`transform_function` to be built on the fly:
iex> [a: 1, b: 2, c: 3, d: :atom]
...> |> realise(
...> filter: [fn {_k,v} -> is_number(v) end, fn {_k,v} -> v > 0 end],
...> map: [fn {k,v} -> {k, v * v} end, fn {k,v} -> {k, v + 42} end],
...> reject: [fn {_k,v} -> v < 45 end, fn {_k,v} -> v > 50 end])
[b: 46]
"""
@spec realise(enum, transform_function | transform_pipeline) :: any
def realise(enum, opts \\ [])
def realise(enum, []) do
enum
|> PEU.maybe_realise
end
def realise(enum, opts) do
transform(enum, opts)
|> PEU.maybe_realise
end
@doc ~S"""
The `defenumtransform/1` macro creates a named `transform function`
from a pipeline of discrete transforms.
## Examples
This example shows the definition of a named `transform function`
called *clean_the_data* that applies a pipeline of `filters`, `maps`
and `rejects` and finally `to_list` to realise the result of the
previous transforms.
defenumtransform clean_the_data(
filter: [fn v -> is_number(v) end, fn v -> v > 0 end],
map: [fn v -> v * v end, fn v -> v + 42 end],
reject: [fn v -> v < 45 end, fn v -> v > 50 end,
to_list: nil])
iex> [-1, make_ref(), 1, :atom, 2, "string", 3, &(&1)] |> clean_the_data
[46]
"""
@spec defenumtransform(transform_pipeline) :: Macro.t
defmacro defenumtransform(args) do
{fun_name, args} = args
|> Macro.decompose_call
quote_opts =
case args |> length do
# should be a keyword
1 -> args |> List.first
_ -> args
end
quote bind_quoted: [
fun_name: fun_name,
quote_opts: quote_opts,
caller_module: __CALLER__.module,
transform_module: __MODULE__,
] do
# create a unique name (atom) for the transform function module attribute
fun_attr_name = "#{fun_name}_#{make_ref() |> inspect |> String.slice(11..-2) |> String.replace(".", "_")}"
|> String.to_atom
# build the transform function
transform_fun = quote_opts |> transform_module.build
# store the transform function in a persistent module attribute
Module.register_attribute(caller_module, fun_attr_name, persist: true)
Module.put_attribute(caller_module, fun_attr_name, transform_fun)
def unquote(fun_name)(enum) do
# get the transform function from the module attribute
transform_fun = :attributes
|> __MODULE__.__info__
|> Keyword.fetch!(unquote(fun_attr_name))
|> List.first
enum |> transform_fun.()
end
end
end
end