Coverage

98.2
116
2809
2

lib/custom_schema.ex

100.0
1
11
0
Line Hits Source
0 import ProtocolEx
1
2 defprotocol_ex Mecto.CustomSchema do
3 @moduledoc """
4 The `protocol_ex` protocol used to create schemas for custom Ecto types.
5 """
6
7 def type(module, data)
8 end
9
10 defimpl_ex Ecto.Enum, Ecto.Enum, for: Mecto.CustomSchema do
11 @moduledoc """
12 Implementation of `Mecto.CustomSchema` for `Ecto.Enum`, using `protocol_ex`.
13 """
14
15 # Note that the module is passed in as a first argument if required, although
16 # it's not needed for `Ecto.Enum`.
17 @spec type(module(), map()) :: Enumerable.t()
18 11 def type(_module, %{type: type}), do: type
19 end

lib/markup_parser.ex

88.8
18
371
2
Line Hits Source
0 defmodule Mecto.MarkupParser do
1 @moduledoc """
2 Parses `"some text with [[tag.module]]"` into `[tag: [:module]]`
3 """
4
5 import NimbleParsec
6 alias Mecto.MarkupParser.Field
7
8 whitespace = utf8_string([?\s, ?\n, ?\r, ?\t], min: 1)
9 opening_tag = string("[[")
10 closing_tag = string("]]")
11
12 tag =
13 ignore(opening_tag)
14 |> ignore(optional(whitespace))
15 |> lookahead_not(closing_tag)
16 |> tag(Field.field(), :field)
17 |> ignore(optional(whitespace))
18 |> ignore(closing_tag)
19
20 text =
21 lookahead_not(opening_tag)
22 |> utf8_string([], 1)
23 |> times(min: 1)
24 |> reduce({Enum, :join, []})
25
26 leading_whitespace =
27 whitespace
28 |> lookahead(opening_tag)
29 |> ignore()
30
31 defparsec(:parse, repeat(choice([tag, text, leading_whitespace])) |> eos())
32
33 @spec extract_nodes(String.t()) :: map() | {:error, String.t()}
34 21 def extract_nodes(text) do
35 21 with {:ok, nodes, _, _, _, _} <- parse(text) do
36 nodes
37 |> Enum.reject(&is_binary/1)
38 15 |> Enum.reduce(%{}, &merge_node/2)
39 else
40 {:error, _e, _, _, _, _} ->
41 {:error, "invalid markup"}
42 end
43 rescue
44 1 e in ArgumentError ->
45 1 if e.message =~ "1st argument: not an already existing atom" do
46 {:error,
47 "field cannot be converted to a non-existing atom. Maybe your markup has a typo?"}
48 else
49 0 raise e
50 end
51 end
52
53 defp merge_node({:field, path}, fields) do
54 24 atomised_path =
55 Enum.map(path, fn
56 7 entry when is_integer(entry) -> entry
57 60 otherwise -> String.to_existing_atom("#{otherwise}")
58 end)
59
60 23 merge_node(fields, atomised_path)
61 end
62
63 0 defp merge_node(fields, []), do: fields
64
65 defp merge_node(fields, [entry]) do
66 23 case get_node(fields, entry) do
67 22 {nil, remainder} -> Map.put(remainder, entry, 1)
68 1 _ -> Map.update!(fields, entry, &(&1 + 1))
69 end
70 end
71
72 defp merge_node(fields, [head | tail]) do
73 43 case get_node(fields, head) do
74 {nil, remainder} ->
75 32 Map.put(remainder, head, merge_node(%{}, tail))
76
77 {node, remainder} ->
78 11 Map.put(remainder, head, merge_node(node, tail))
79 end
80 end
81
82 defp get_node(fields, node) do
83 66 Map.pop(fields, node)
84 end
85 end

lib/mecto.ex

100.0
19
184
0
Line Hits Source
0 defmodule Mecto do
1 @moduledoc """
2 "Mail merging" with Ecto structs.
3
4 A parser to interpolate MediaWiki-like `[[foo.bar]]` markup using data from Ecto schemas.
5 """
6
7 @doc """
8 Extracts nodes from some markup, returning a map where the end values are the count
9 of how many times that node was in the markup.
10
11 Note: this doesn't validate the nodes, just extracts them or errors if the markup is
12 invalid.
13
14 ## Examples
15
16 iex> Mecto.extract_nodes("some text [[blog_post.title]] and [[blog_post.comments[0].content]]")
17 %{blog_post: %{title: 1, comments: %{0 => %{content: 1}}}}
18
19 iex> Mecto.extract_nodes("some text that [[is|invalid]]")
20 {:error, "invalid markup"}
21
22 """
23 @spec extract_nodes(String.t()) :: map() | {:error, String.t()}
24 21 defdelegate extract_nodes(text), to: Mecto.MarkupParser
25
26 @doc """
27 Builds a map from an Ecto schema, including following associations.
28
29 Note: this will not recurse if a struct is repeated deeper in the tree.
30
31 ## Examples
32
33 iex> Mecto.parse_schema(Mecto.User)
34 %{id: :id, username: :string}
35
36 iex> Mecto.parse_schema(Mecto.Foo)
37 {:error, :missing_schema}
38
39 """
40 @spec parse_schema(module()) :: map() | {:error, atom()}
41 15 defdelegate parse_schema(module), to: Mecto.SchemaExtractor, as: :convert_from
42
43 @doc """
44 Validates markup fields exist on the supplied schema.
45
46 ## Examples
47
48 iex> Mecto.validate("Title for post #[[blog_post.id]]", Mecto.BlogPost)
49 %{blog_post: %{id: :id}}
50
51 iex> Mecto.validate("[[blog_post.invalid_field]]", Mecto.BlogPost)
52 {:error, ["blog_post.invalid_field does not exist"]}
53
54 """
55 @spec validate(String.t(), module()) :: map() | {:error, [String.t()]}
56 def validate(text, module) do
57 9 schema = parse_schema(module)
58 9 keyed_schema = keyed_map(module, schema)
59
60 9 case extract_nodes(text) do
61 1 {:error, e} -> {:error, [e]}
62 8 nodes -> Mecto.SchemaValidator.check(keyed_schema, nodes)
63 end
64 end
65
66 @doc """
67 Interpolates markup with the values in the given Ecto structs.
68
69 Note: this does _not_ validate the markup (beyond checking it can be parsed) - it is
70 expected that any text passed to this has already been validated with `Mecto.validate/2`
71
72 ## Examples
73
74 iex> Mecto.interpolate("Title for post [[blog_post.title]]", %Mecto.BlogPost{title: "some title"})
75 {:ok, "Title for post some title"}
76
77 iex> Mecto.interpolate("Some [[[invalid markup]]", %Mecto.BlogPost{})
78 {:error, "invalid markup"}
79
80 iex> Mecto.interpolate("Some [[blog_post.invalid_field]]", %Mecto.BlogPost{})
81 {:ok, "Some "}
82
83 """
84 @spec interpolate(String.t(), map()) :: {:ok, String.t()} | {:error, String.t()}
85 def interpolate(text, data) do
86 9 %module{} = data
87 9 keyed_data = keyed_map(module, data)
88
89 9 case Mecto.MarkupParser.parse(text) do
90 2 {:error, _e, _, _, _, _} ->
91 {:error, "invalid markup"}
92
93 {:ok, nodes, _, _, _, _} ->
94 nodes
95 |> Enum.map(fn
96 node when is_binary(node) ->
97 9 node
98
99 {:field, path} ->
100 7 path =
101 Enum.map(path, fn
102 path_entry when is_integer(path_entry) ->
103 1 Access.at(path_entry)
104
105 path_entry ->
106 path_entry
107 |> String.to_existing_atom()
108 16 |> Access.key()
109 end)
110
111 7 get_in(keyed_data, path)
112 end)
113 |> Enum.join()
114 7 |> then(&{:ok, &1})
115 end
116 end
117
118 @spec keyed_map(module(), map()) :: map()
119 defp keyed_map(module, map) do
120 18 module_key =
121 Atom.to_string(module)
122 |> String.split(".")
123 |> List.last()
124 |> Macro.underscore()
125 |> String.to_atom()
126
127 18 %{module_key => map}
128 end
129 end

lib/schema_extractor.ex

100.0
46
2035
0
Line Hits Source
0 defmodule Mecto.SchemaExtractor do
1 @moduledoc """
2 Converts an Ecto schema into a map, for use in `Mecto.SchemaValidator`.
3 """
4
5 @type cardinality :: :one | :many
6 @type association :: {:direct, atom()} | {:indirect, atom()}
7
8 @spec convert_from(module()) :: map() | {:error, atom()}
9 def convert_from(module) do
10 15 case Code.ensure_compiled(module) do
11 {:module, module} ->
12 14 if function_exported?(module, :__schema__, 1) do
13 12 convert_schema_to_map(module, [module])
14 else
15 {:error, :missing_schema}
16 end
17
18 error ->
19 1 error
20 end
21 end
22
23 @spec convert_schema_to_map(module(), list()) :: map()
24 defp convert_schema_to_map(module, visited) do
25 45 fields =
26 :fields
27 |> module.__schema__()
28 145 |> Enum.map(fn field -> {field, module.__schema__(:type, field)} end)
29
30 45 {embedded_associations, fields} =
31 Enum.split_with(fields, fn
32 11 {_, {:parameterized, Ecto.Embedded, _}} -> true
33 134 _field -> false
34 end)
35
36 45 fields =
37 fields
38 |> Enum.map(&map_custom_type/1)
39 |> Enum.into(%{})
40
41 45 fields =
42 embedded_associations
43 11 |> Enum.map(fn {field, _} -> field end)
44 11 |> Enum.map(&list_associations(module, &1))
45 11 |> Enum.group_by(fn {type, _} -> type end)
46 |> Map.get(:direct, [])
47 11 |> Enum.map(&map_association(module, visited, &1))
48 |> Enum.into(fields)
49
50 45 associations =
51 :associations
52 |> module.__schema__()
53 55 |> Enum.map(&list_associations(module, &1))
54 55 |> Enum.group_by(fn {type, _} -> type end)
55
56 associations
57 |> Map.get(:direct, [])
58 44 |> Enum.map(&map_association(module, visited, &1))
59 44 |> Enum.reject(&is_nil/1)
60 |> Enum.into(fields)
61 45 |> link_indirect_associations(module, Map.get(associations, :indirect))
62 end
63
64 @spec list_associations(module(), atom()) :: association()
65 defp list_associations(module, association) do
66 66 relationship = get_relationship(module, association)
67
68 66 case Map.get(relationship || %{}, :related) do
69 11 nil -> {:indirect, association}
70 55 _otherwise -> {:direct, association}
71 end
72 end
73
74 @spec map_association(module(), list(), {:direct, atom()}) ::
75 {atom(), {cardinality(), map()}} | nil
76 defp map_association(module, visited, {:direct, association}) do
77 55 relationship = get_relationship(module, association) || %{}
78 55 associated_module = Map.get(relationship, :related)
79
80 55 if Code.ensure_loaded?(associated_module) do
81 55 if Enum.member?(visited, associated_module) do
82 nil
83 else
84 {
85 association,
86 {
87 33 relationship.cardinality,
88 convert_schema_to_map(associated_module, [associated_module | visited])
89 }
90 }
91 end
92 end
93 end
94
95 @spec link_indirect_associations(map(), module(), [association()]) :: map()
96 34 defp link_indirect_associations(schema, _module, nil), do: schema
97
98 defp link_indirect_associations(schema, module, associations) do
99 11 Enum.reduce(associations, schema, fn {:indirect, association}, schema ->
100 11 relationship = module.__schema__(:association, association)
101
102 11 path =
103 11 relationship.through
104 22 |> Enum.flat_map(&[&1, Access.elem(1)])
105 |> Enum.drop(-1)
106
107 11 case get_in(schema, path) do
108 10 nil -> schema
109 1 association_to_insert -> Map.put(schema, association, association_to_insert)
110 end
111 end)
112 end
113
114 11 defp map_custom_type({label, {:parameterized, type, data}}) do
115 {label, Mecto.CustomSchema.type(type, data)}
116 end
117
118 123 defp map_custom_type(type), do: type
119
120 @spec get_relationship(module(), atom()) :: struct() | nil
121 defp get_relationship(module, association) do
122 121 association =
123 121 module.__schema__(:association, association) || module.__schema__(:type, association)
124
125 121 case association do
126 22 {:parameterized, _module, relationship} -> relationship
127 99 relationship -> relationship
128 end
129 end
130 end

lib/schema_validator.ex

100.0
19
124
0
Line Hits Source
0 defmodule Mecto.SchemaValidator do
1 @moduledoc """
2 Validates fields extracted with `Mecto.MarkupParser` exist based on the schema given from
3 `Mecto.SchemaExtractor`.
4 """
5
6 alias Mecto.SchemaValidator.Result
7
8 @spec check(map(), map()) :: map() | {:error, [String.t()]}
9 def check(schema, nodes) do
10 8 result = check(schema, nodes, [])
11
12 8 if result.error == [] do
13 3 result.ok
14 else
15 5 {:error, result.error}
16 end
17 end
18
19 @spec check(map(), map(), list()) :: map() | {:error, [String.t()]}
20 defp check(schema, nodes, path),
21 21 do: Enum.reduce(nodes, %Result{path: path}, &check_node(schema, &1, &2))
22
23 @spec check_node(map(), {atom(), map()}, Result.t()) :: map() | {:error, [String.t()]}
24 defp check_node(schema, {node, nested_nodes}, %Result{path: path} = result) do
25 25 case Map.get(schema, node) do
26 nil ->
27 6 Result.add_error(result, node, "does not exist")
28
29 value when is_atom(value) ->
30 4 Result.merge(result, node, value)
31
32 enum when is_map(enum) ->
33 8 nested_result = check(enum, nested_nodes, path ++ [node])
34 8 Result.merge(result, node, nested_result)
35
36 {cardinality, enum} when is_map(enum) ->
37 7 integer_keys? =
38 nested_nodes
39 |> Map.keys()
40 |> Enum.all?(&is_integer/1)
41
42 7 case {cardinality, integer_keys?} do
43 {:one, false} ->
44 3 nested_result = check(enum, nested_nodes, path ++ [node])
45 3 Result.merge(result, node, nested_result)
46
47 {:many, true} ->
48 2 nested_result =
49 2 Enum.map(nested_nodes, fn {index, nested_nodes} ->
50 {index, check(enum, nested_nodes, path ++ [node, index])}
51 end)
52
53 2 Result.merge(result, node, nested_result)
54
55 {:one, true} ->
56 1 Result.add_error(
57 result,
58 node,
59 "has a cardinality of :one, but is being used like a list"
60 )
61
62 {:many, false} ->
63 1 Result.add_error(
64 result,
65 node,
66 "have a cardinality of :many, but is being used like a single element"
67 )
68 end
69 end
70 end
71 end

lib/schema_validator/result.ex

100.0
13
84
0
Line Hits Source
0 defmodule Mecto.SchemaValidator.Result do
1 @moduledoc """
2 Used by `Mecto.SchemaValidator` to track the results of schema validation.
3 """
4
5 alias __MODULE__, as: Self
6
7 @type t :: %__MODULE__{path: list(), ok: map, error: list}
8 defstruct path: [], ok: %{}, error: []
9
10 def merge(%Self{} = result, key, value) when is_atom(value),
11 4 do: %{result | ok: Map.put(result.ok, key, value)}
12
13 def merge(%Self{} = result, key, %Self{} = other) do
14 15 if Enum.empty?(other.error) do
15 6 %{result | ok: Map.put(result.ok, key, other.ok)}
16 else
17 9 %{result | error: result.error ++ other.error}
18 end
19 end
20
21 def merge(%Self{} = result, key, other) when is_list(other) do
22 2 other =
23 Enum.reduce(other, %Self{}, fn {key, a}, b ->
24 2 merge(b, key, a)
25 end)
26
27 2 merge(result, key, other)
28 end
29
30 def add_error(%Self{} = result, field, error) do
31 8 path =
32 8 result.path
33 |> Enum.concat([field])
34 |> Enum.reduce(fn
35 path_entry, path when is_integer(path_entry) ->
36 1 "#{path}[#{path_entry}]"
37
38 path_entry, path ->
39 11 "#{path}.#{path_entry}"
40 end)
41
42 8 error = "#{path} #{error}"
43
44 8 %{result | error: [error | result.error]}
45 end
46 end