changed .formatter.exs
 
@@ -1,4 1,4 @@
1
- # Used by "mix format"
2
- [
3
- inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4
- ]
1
# Used by "mix format"
2
[
3
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
4
]
added CHANGELOG.md
 
@@ -0,0 1,9 @@
1
# Changelog
2
3
<!-- %% CHANGELOG_ENTRIES %% -->
4
5
### 0.4.0 - 2021-07-20 22:07:19
6
7
Refactored the code; not many user visible changes.
8
Added the `publish` mix task which helps preparing the hex package to be published.
9
changed LICENSE.txt
 
@@ -1,7 1,7 @@
1
- Copyright 2018 Tiago Barroso
2
-
3
- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
-
5
- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
-
1
Copyright 2018 Tiago Barroso
2
3
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
5
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
7
7
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
\ No newline at end of file
changed README.md
 
@@ -1,65 1,65 @@
1
- ### Warning
2
-
3
- This is currently very hackish.
4
- Code organization is not the best, and APIs might change without notice.
5
- You might think of using this in production, but be warned that this might break.
6
-
7
- # Forage
8
-
9
- Dynamic Ecto queries for ecto, with adapters for Plug applications.
10
- It was inspired by Rummage, but with a different API.
11
- It doesn't share any actual code.
12
-
13
- ## Installation
14
-
15
- This package is available on [hex.pm](https://hex.pm/packages/forage).
16
- It can be installed like any other hex package.
17
-
18
- ## Overview
19
-
20
- This package is divided into two namespaces: `Forage` and `ForageWeb`.
21
-
22
- If you're building a Phoenix application, the `Forage` namespace is something which you want use in your contexts, while the `ForageWeb` is something which you'll want to use in your views (`ForageWeb.ForageView`) and controllers (`ForageWeb.ForageController`).
23
-
24
- ### Forage
25
-
26
- The `Forage` namespace contains the query generator, which converts maps in a certain format into ecto queries at runtime in a safe way (i.e., it can't be used to attack the BEAM).
27
-
28
- This can be useful to generate dynamic ecto queries from request `params` inside a Plug (or Phoenix) application.
29
-
30
- However, the functions in the `Forage` namespace are independent of Plug, and can be used anywhere you might need to build a dynamic Ecto query.
31
-
32
- ### ForageWeb
33
-
34
- The `ForageWeb` namespace contains utilities to integrate with Plug applications.
35
- It contains form input widgets which can be used to build search filters, pagination widgets and sort links.
36
- These input widgets are built in a way such that when the request is parsed by Plug, the `params` map will be compatible with the query builders in the `Forage` namespace.
37
-
38
- Currently this package is used by the [mandarin](https://github.com/tmbb/mandarin). package.
39
-
40
- ## Features
41
-
42
- `Forage` supports:
43
-
44
- 1. Search filter, i.e. Ecto queries with `where` clauses
45
- 2. Sorting, i.e. Ecto queries with an `order_by` clause
46
- 3. Pagination, using the [paginator](https://github.com/duffelhq/paginator) package,
47
- for cursor-based pagination.
48
- This approach is more efficient than the more common offset-based pagination.
49
- [Thhe links here](https://github.com/duffelhq/paginator#learn-more)
50
- go over the main differences.
51
-
52
- ## Usage
53
-
54
- TODO
55
-
56
- ## Contributing
57
-
58
- Pull requests (both code and documentation are appreciated)
59
-
60
- ## Roadmap
61
-
62
- 1. Add tests
63
- 2. Add a richer set of widgets where it makes sense
64
- 3. Decide on how to package the javascript code on the frontend
1
### Warning
2
3
This is currently very hackish.
4
Code organization is not the best, and APIs might change without notice.
5
You might think of using this in production, but be warned that this might break.
6
7
# Forage
8
9
Dynamic ecto queries for ecto, with adapters for Plug applications.
10
It was inspired by Rummage, but with a different API.
11
It doesn't share any actual code.
12
13
## Installation
14
15
This package is available on [hex.pm](https://hex.pm/packages/forage).
16
It can be installed like any other hex package.
17
18
## Overview
19
20
This package is divided into two namespaces: `Forage` and `ForageWeb`.
21
22
If you're building a Phoenix application, the `Forage` namespace is something which you want use in your contexts, while the `ForageWeb` is something which you'll want to use in your views (`ForageWeb.ForageView`) and controllers (`ForageWeb.ForageController`).
23
24
### Forage
25
26
The `Forage` namespace contains the query generator, which converts maps in a certain format into ecto queries at runtime in a safe way (i.e., it can't be used to attack the BEAM).
27
28
This can be useful to generate dynamic ecto queries from request `params` inside a Plug (or Phoenix) application.
29
30
However, the functions in the `Forage` namespace are independent of Plug, and can be used anywhere you might need to build a dynamic Ecto query.
31
32
### ForageWeb
33
34
The `ForageWeb` namespace contains utilities to integrate with Plug applications.
35
It contains form input widgets which can be used to build filters, pagination widgets and sort links.
36
These input widgets are built in a way such that when the request is parsed by Plug, the `params` map will be compatible with the query builders in the `Forage` namespace.
37
38
Currently this package is used by the [mandarin](https://github.com/tmbb/mandarin). package.
39
40
## Features
41
42
`Forage` supports:
43
44
1. Search filter, i.e. Ecto queries with `where` clauses
45
2. Sorting, i.e. Ecto queries with an `order_by` clause
46
3. Pagination, using the [paginator](https://github.com/duffelhq/paginator) package,
47
for cursor-based pagination.
48
This approach is more efficient than the more common offset-based pagination.
49
[Thhe links here](https://github.com/duffelhq/paginator#learn-more)
50
go over the main differences.
51
52
## Usage
53
54
TODO
55
56
## Contributing
57
58
Pull requests (both code and documentation are appreciated)
59
60
## Roadmap
61
62
1. Add tests
63
2. Add a richer set of widgets where it makes sense
64
3. Decide on how to package the javascript code on the frontend
65
65
4. Support the SQL `or` operators. Currently only the `and` operator is supported.
\ No newline at end of file
changed hex_metadata.config
 
@@ -3,24 3,26 @@
3
3
{<<"description">>,<<"Dynamic ecto query builder">>}.
4
4
{<<"elixir">>,<<"~> 1.7">>}.
5
5
{<<"files">>,
6
- [<<"lib">>,<<"lib/forage">>,<<"lib/forage/codec">>,
7
- <<"lib/forage/codec/decoder.ex">>,<<"lib/forage/codec/encoder.ex">>,
8
- <<"lib/forage/codec/exceptions">>,
6
[<<"lib">>,<<"lib/forage">>,<<"lib/forage/assoc_loader.ex">>,
7
<<"lib/forage/codec">>,<<"lib/forage/codec/decoder.ex">>,
8
<<"lib/forage/codec/encoder.ex">>,<<"lib/forage/codec/exceptions">>,
9
9
<<"lib/forage/codec/exceptions/invalid_assoc_error.ex">>,
10
10
<<"lib/forage/codec/exceptions/invalid_field_error.ex">>,
11
11
<<"lib/forage/codec/exceptions/invalid_pagination_data_error.ex">>,
12
12
<<"lib/forage/codec/exceptions/invalid_sort_direction_error.ex">>,
13
13
<<"lib/forage/forage_plan.ex">>,<<"lib/forage/paginator.ex">>,
14
- <<"lib/forage/query_builder">>,<<"lib/forage/query_builder/search_filter">>,
15
- <<"lib/forage/query_builder/search_filter/add_filter_to_query.ex">>,
16
- <<"lib/forage/query_builder/search_filter.ex">>,
14
<<"lib/forage/query_builder">>,<<"lib/forage/query_builder/filter">>,
15
<<"lib/forage/query_builder/filter/add_filter_to_query.ex">>,
16
<<"lib/forage/query_builder/filter.ex">>,
17
17
<<"lib/forage/query_builder/sort_field.ex">>,
18
- <<"lib/forage/query_builder.ex">>,<<"lib/forage.ex">>,<<"lib/forage_web">>,
19
- <<"lib/forage_web/assets">>,<<"lib/forage_web/assets/datepicker.js">>,
18
<<"lib/forage/query_builder.ex">>,<<"lib/forage/search.ex">>,
19
<<"lib/forage.ex">>,<<"lib/forage_web">>,<<"lib/forage_web/assets">>,
20
<<"lib/forage_web/assets/datepicker.js">>,
20
21
<<"lib/forage_web/assets/select2.js">>,<<"lib/forage_web/assets.ex">>,
21
22
<<"lib/forage_web/display.ex">>,<<"lib/forage_web/forage_controller.ex">>,
22
23
<<"lib/forage_web/forage_view.ex">>,<<"lib/forage_web/naming.ex">>,
23
- <<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>,<<"LICENSE.txt">>]}.
24
<<".formatter.exs">>,<<"mix.exs">>,<<"README.md">>,<<"LICENSE.txt">>,
25
<<"CHANGELOG.md">>]}.
24
26
{<<"licenses">>,[<<"MIT">>]}.
25
27
{<<"links">>,[{<<"GitHub">>,<<"https://github.com/tmbb/forage">>}]}.
26
28
{<<"name">>,<<"forage">>}.
 
@@ -35,9 37,14 @@
35
37
{<<"optional">>,false},
36
38
{<<"repository">>,<<"hexpm">>},
37
39
{<<"requirement">>,<<"~> 2.10">>}],
40
[{<<"app">>,<<"json">>},
41
{<<"name">>,<<"json">>},
42
{<<"optional">>,false},
43
{<<"repository">>,<<"hexpm">>},
44
{<<"requirement">>,<<">= 0.0.0">>}],
38
45
[{<<"app">>,<<"paginator">>},
39
46
{<<"name">>,<<"paginator">>},
40
47
{<<"optional">>,false},
41
48
{<<"repository">>,<<"hexpm">>},
42
- {<<"requirement">>,<<"~> 0.6.0">>}]]}.
43
- {<<"version">>,<<"0.3.0">>}.
49
{<<"requirement">>,<<"~> 1.0">>}]]}.
50
{<<"version">>,<<"0.4.0">>}.
changed lib/forage.ex
 
@@ -1,28 1,39 @@
1
- defmodule Forage do
2
- @moduledoc """
3
- Documentation for Forage.
4
- """
5
- import Ecto.Query, only: [where: 3]
6
- alias Forage.Codec.Decoder
7
-
8
- defdelegate build_query(params, schema, options), to: Forage.QueryBuilder
9
- defdelegate paginate(params, schema, options, repo_opts), to: Forage.Paginator
10
-
11
- @doc """
12
- ABC
13
- """
14
- def cast_related(params, schema, repo) do
15
- for {key, value} <- params, into: %{} do
16
- case key do
17
- "__forage_select_many__" <> name ->
18
- remote_schema = Decoder.remote_schema(name, schema)
19
- # In this case, value is a list of `id`s
20
- items = remote_schema |> where([p], p.id in ^value) |> repo.all()
21
- {name, items}
22
-
23
- name ->
24
- {name, value}
25
- end
26
- end
27
- end
28
- end
1
defmodule Forage do
2
@moduledoc """
3
Documentation for Forage.
4
"""
5
import Ecto.Query, only: [where: 3]
6
alias Ecto.Changeset
7
alias Forage.Codec.Decoder
8
9
defdelegate build_query(params, schema, options), to: Forage.QueryBuilder
10
defdelegate paginate(params, schema, options, repo_opts), to: Forage.Paginator
11
defdelegate load_assocs(repo, schema, attrs), to: Forage.AssocLoader
12
defdelegate naive_search_params(params, field), to: Forage.Search
13
14
@doc """
15
TODO
16
"""
17
def cast_related(repo, schema, attrs) do
18
for {key, value} <- attrs, into: %{} do
19
case key do
20
"__forage_select_many__" <> name ->
21
{remote_schema, _name_as_atom} = Decoder.remote_schema_and_field_name_as_atom(name, schema)
22
# In this case, value is a list of `id`s
23
items = remote_schema |> where([p], p.id in ^value) |> repo.all()
24
{name, items}
25
26
name ->
27
{name, value}
28
end
29
end
30
end
31
32
@doc """
33
TODO
34
"""
35
def put_assoc(%Changeset{} = changeset, attrs, field) do
36
assoc = Map.get(attrs, to_string(field), [])
37
Changeset.put_assoc(changeset, field, assoc)
38
end
39
end
added lib/forage/assoc_loader.ex
 
@@ -0,0 1,41 @@
1
defmodule Forage.AssocLoader do
2
import Ecto.Query, only: [from: 2]
3
4
defp string_to_existing_atom(s) do
5
try do
6
{:ok, String.to_existing_atom(s)}
7
rescue
8
ArgumentError -> :error
9
end
10
end
11
12
def struct_to_map(map) do
13
for {k, v} <- map, into: %{} do
14
{to_string(k), v}
15
end
16
end
17
18
def load_assocs(repo, schema, attrs) do
19
assoc_fields = schema.__schema__(:associations)
20
21
for {k, v} <- attrs, into: %{} do
22
case string_to_existing_atom(k) do
23
{:ok, k_atom} ->
24
case k_atom in assoc_fields and is_list(v) and Enum.any?(v, fn i -> not is_map(i) end) do
25
true ->
26
assoc = schema.__schema__(:association, k_atom)
27
queryable = assoc.queryable
28
items = repo.all(from(u in queryable, where: u.id in ^v))
29
30
{k, items}
31
32
false ->
33
{k, v}
34
end
35
36
:error ->
37
{k, v}
38
end
39
end
40
end
41
end
changed lib/forage/codec/decoder.ex
 
@@ -1,165 1,198 @@
1
- defmodule Forage.Codec.Decoder do
2
- @moduledoc """
3
- Functionality to decode a Phoenix `params` map into a form suitable for use
4
- with the query builders and pagination libraries
5
- """
6
- alias Forage.Codec.Exceptions.InvalidAssocError
7
- alias Forage.Codec.Exceptions.InvalidFieldError
8
- alias Forage.Codec.Exceptions.InvalidSortDirectionError
9
- alias Forage.Codec.Exceptions.InvalidPaginationDataError
10
- alias Forage.ForagePlan
11
-
12
- @type schema() :: atom()
13
- @type assoc() :: {schema(), atom(), atom()}
14
-
15
- @doc """
16
- Encodes a params map into a forage plan (`Forage.ForagerPlan`).
17
- """
18
- def decode(params, schema) do
19
- search = decode_search(params, schema)
20
- sort = decode_sort(params, schema)
21
- pagination = decode_pagination(params, schema)
22
- %ForagePlan{search: search, sort: sort, pagination: pagination}
23
- end
24
-
25
- @doc """
26
- Extract and decode the search filters from the `params` map into a list of filters.
27
- """
28
- def decode_search(%{"_search" => search_data}, schema) do
29
- decoded_fields =
30
- for {field_string, %{"op" => op, "val" => val}} <- search_data do
31
- field_or_assoc = decode_field_or_assoc(field_string, schema)
32
- [field: field_or_assoc, operator: op, value: val]
33
- end
34
-
35
- Enum.sort(decoded_fields)
36
- end
37
-
38
- def decode_search(_params, _schema), do: []
39
-
40
- def decode_field_or_assoc(field_string, schema) do
41
- parts = String.split(field_string, ".")
42
-
43
- case parts do
44
- [field_name] ->
45
- field = safe_field_name_to_atom!(field_name, schema)
46
- {:simple, field}
47
-
48
- [local_name, remote_name] ->
49
- assoc = safe_field_names_to_assoc!(local_name, remote_name, schema)
50
- {:assoc, assoc}
51
-
52
- _ ->
53
- raise ArgumentError, "Invalid field name '#{field_string}'."
54
- end
55
- end
56
-
57
- @doc """
58
- Extract and decode the sort fields from the `params` map into a keyword list.
59
- """
60
- def decode_sort(%{"_sort" => sort}, schema) do
61
- # TODO: make this more robust
62
- decoded =
63
- for {field_name, %{"direction" => direction}} <- sort do
64
- field_atom = safe_field_name_to_atom!(field_name, schema)
65
- direction = decode_direction(direction)
66
- [field: field_atom, direction: direction]
67
- end
68
-
69
- # Sort the result so that the order is always the same
70
- Enum.sort(decoded)
71
- end
72
-
73
- def decode_sort(_params, _schema), do: []
74
-
75
- @doc """
76
- Extract and decode the pagination data from the `params` map into a keyword list.
77
- """
78
- def decode_pagination(%{"_pagination" => pagination}, _schema) do
79
- decoded_after =
80
- case pagination["after"] do
81
- nil -> []
82
- after_ -> [after: after_]
83
- end
84
-
85
- decoded_before =
86
- case pagination["before"] do
87
- nil -> []
88
- before -> [before: before]
89
- end
90
-
91
- decoded_after decoded_before
92
- end
93
-
94
- def decode_pagination(_params, _schema), do: []
95
-
96
- @spec decode_direction(String.t() | nil) :: atom() | nil
97
- defp decode_direction("asc"), do: :asc
98
- defp decode_direction("desc"), do: :desc
99
- defp decode_direction(nil), do: nil
100
- defp decode_direction(value), do: raise(InvalidSortDirectionError, value)
101
-
102
- def pagination_data_to_integer!(value) do
103
- try do
104
- String.to_integer(value)
105
- rescue
106
- ArgumentError -> raise InvalidPaginationDataError, value
107
- end
108
- end
109
-
110
- @spec safe_field_names_to_assoc!(String.t(), String.t(), atom()) :: assoc()
111
- def safe_field_names_to_assoc!(local_name, remote_name, local_schema) do
112
- local = safe_assoc_name_to_atom!(local_name, local_schema)
113
- remote_schema = local_schema.__schema__(:association, local).related
114
- remote = safe_field_name_to_atom!(remote_name, remote_schema)
115
- {remote_schema, local, remote}
116
- end
117
-
118
- def remote_schema(local_name, local_schema) do
119
- local = safe_assoc_name_to_atom!(local_name, local_schema)
120
- remote_schema = local_schema.__schema__(:association, local).related
121
- remote_schema
122
- end
123
-
124
- @doc false
125
- @spec safe_assoc_name_to_atom!(String.t(), schema()) :: atom()
126
- def safe_assoc_name_to_atom!(assoc_name, schema) do
127
- # This function performs the dangerous job of turning a string into an atom.
128
- schema_associations = schema.__schema__(:associations)
129
- found = Enum.find(schema_associations, fn assoc -> assoc_name == Atom.to_string(assoc) end)
130
-
131
- case found do
132
- nil ->
133
- raise InvalidAssocError, {schema, assoc_name}
134
-
135
- _ ->
136
- found
137
- end
138
- end
139
-
140
- @doc false
141
- @spec safe_field_name_to_atom!(String.t(), schema()) :: atom()
142
- def safe_field_name_to_atom!(field_name, schema) do
143
- # This function performs the dangerous job of turning a string into an atom.
144
- # Because the atom table on the BEAM is limited, there is a limit of atoms that can exist.
145
- # This means generating atoms at runtime is very dangerous,
146
- # especially if they're being generated from user input.
147
- # The whole goal of `forage` is to generate process "raw" (i.e. untrusted) user input,
148
- # so we must be especially careful.
149
- # Using `String.to_atom()` is completely out of the question.
150
- # Using `String.to_existing_atom()` is a possibility, but we have chosen to do it in another way.
151
- # Instead of turning the string into an atom, we iterate over the schema fields,
152
- # convert them into strings and check the strings for equality.
153
- # When we find a match, we return the atom.
154
- schema_fields = schema.__schema__(:fields)
155
- found = Enum.find(schema_fields, fn field -> field_name == Atom.to_string(field) end)
156
-
157
- case found do
158
- nil ->
159
- raise InvalidFieldError, {schema, field_name}
160
-
161
- _ ->
162
- found
163
- end
164
- end
165
- end
1
defmodule Forage.Codec.Decoder do
2
@moduledoc """
3
Functionality to decode a Phoenix `params` map into a form suitable for use
4
with the query builders and pagination libraries
5
"""
6
alias Forage.Codec.Exceptions.InvalidAssocError
7
alias Forage.Codec.Exceptions.InvalidFieldError
8
alias Forage.Codec.Exceptions.InvalidSortDirectionError
9
alias Forage.Codec.Exceptions.InvalidPaginationDataError
10
alias Forage.ForagePlan
11
12
@type schema() :: atom()
13
@type assoc() :: {schema(), atom(), atom()}
14
15
@doc """
16
Encodes a params map into a forage plan (`Forage.ForagerPlan`).
17
"""
18
def decode(params, schema) do
19
filter = decode_filter(params, schema)
20
sort = decode_sort(params, schema)
21
pagination = decode_pagination(params, schema)
22
%ForagePlan{filter: filter, sort: sort, pagination: pagination}
23
end
24
25
@doc """
26
Extract and decode the filter filters from the `params` map into a list of filters.
27
28
## Examples:
29
30
TODO
31
"""
32
def decode_filter(%{"_filter" => filter_data}, schema) do
33
decoded_fields =
34
for {field_string, %{"op" => op, "val" => val}} <- filter_data do
35
field_or_assoc = decode_field_or_assoc(field_string, schema)
36
[field: field_or_assoc, operator: op, value: val]
37
end
38
39
Enum.sort(decoded_fields)
40
end
41
42
def decode_filter(_params, _schema), do: []
43
44
def decode_field_or_assoc(field_string, schema) do
45
parts = String.split(field_string, ".")
46
47
case parts do
48
[field_name] ->
49
field = safe_field_name_to_atom!(field_name, schema)
50
{:simple, field}
51
52
[local_name, remote_name] ->
53
assoc = safe_field_names_to_assoc!(local_name, remote_name, schema)
54
{:assoc, assoc}
55
56
_ ->
57
raise ArgumentError, "Invalid field name '#{field_string}'."
58
end
59
end
60
61
@doc """
62
Extract and decode the sort fields from the `params` map into a keyword list.
63
To be used with a schema.
64
"""
65
def decode_sort(%{"_sort" => sort}, schema) do
66
# TODO: make this more robust
67
decoded =
68
for {field_name, %{"direction" => direction}} <- sort do
69
field_atom = safe_field_name_to_atom!(field_name, schema)
70
direction = decode_direction(direction)
71
[field: field_atom, direction: direction]
72
end
73
74
# Sort the result so that the order is always the same
75
Enum.sort(decoded)
76
end
77
78
def decode_sort(_params, _schema), do: []
79
80
@doc """
81
Extract and decode the sort fields from the `params` map into a keyword list.
82
To be used without a schema.
83
"""
84
def decode_sort(%{"_sort" => sort}) do
85
# TODO: make this more robust
86
decoded =
87
for {field_name, %{"direction" => direction}} <- sort do
88
field_atom = safe_field_name_to_atom!(field_name)
89
direction = decode_direction(direction)
90
[field: field_atom, direction: direction]
91
end
92
93
# Sort the result so that the order is always the same
94
Enum.sort(decoded)
95
end
96
97
def decode_sort(_params), do: []
98
99
@doc """
100
Extract and decode the pagination data from the `params` map into a keyword list.
101
"""
102
def decode_pagination(%{"_pagination" => pagination}, _schema) do
103
decoded_after =
104
case pagination["after"] do
105
nil -> []
106
after_ -> [after: after_]
107
end
108
109
decoded_before =
110
case pagination["before"] do
111
nil -> []
112
before -> [before: before]
113
end
114
115
decoded_after decoded_before
116
end
117
118
def decode_pagination(_params, _schema), do: []
119
120
@spec decode_direction(String.t() | nil) :: atom() | nil
121
defp decode_direction("asc"), do: :asc
122
defp decode_direction("desc"), do: :desc
123
defp decode_direction(nil), do: nil
124
defp decode_direction(value), do: raise(InvalidSortDirectionError, value)
125
126
def pagination_data_to_integer!(value) do
127
try do
128
String.to_integer(value)
129
rescue
130
ArgumentError -> raise InvalidPaginationDataError, value
131
end
132
end
133
134
@doc false
135
@spec safe_field_names_to_assoc!(String.t(), String.t(), atom()) :: assoc()
136
def safe_field_names_to_assoc!(local_name, remote_name, local_schema) do
137
local = safe_assoc_name_to_atom!(local_name, local_schema)
138
remote_schema = local_schema.__schema__(:association, local).related
139
remote = safe_field_name_to_atom!(remote_name, remote_schema)
140
{remote_schema, local, remote}
141
end
142
143
@doc false
144
def remote_schema(local_name, local_schema) do
145
local = safe_assoc_name_to_atom!(local_name, local_schema)
146
remote_schema = local_schema.__schema__(:association, local).related
147
remote_schema
148
end
149
150
@doc false
151
def remote_schema_and_field_name_as_atom(local_name_as_string, local_schema) do
152
local = safe_assoc_name_to_atom!(local_name_as_string, local_schema)
153
remote_schema = local_schema.__schema__(:association, local).related
154
{remote_schema, local}
155
end
156
157
@doc false
158
@spec safe_assoc_name_to_atom!(String.t(), schema()) :: atom()
159
def safe_assoc_name_to_atom!(assoc_name, schema) do
160
# This function performs the dangerous job of turning a string into an atom.
161
schema_associations = schema.__schema__(:associations)
162
found = Enum.find(schema_associations, fn assoc -> assoc_name == Atom.to_string(assoc) end)
163
164
case found do
165
nil ->
166
raise InvalidAssocError, {schema, assoc_name}
167
168
_ ->
169
found
170
end
171
end
172
173
@doc false
174
@spec safe_field_name_to_atom!(String.t()) :: atom()
175
def safe_field_name_to_atom!(field_name) do
176
String.to_existing_atom(field_name)
177
end
178
179
@doc false
180
@spec safe_field_name_to_atom!(String.t(), schema()) :: atom()
181
def safe_field_name_to_atom!(field_name, schema) do
182
schema_fields = schema.__schema__(:fields)
183
184
try do
185
field_name_as_atom = String.to_existing_atom(field_name)
186
case field_name_as_atom in schema_fields do
187
true ->
188
field_name_as_atom
189
190
false ->
191
raise InvalidFieldError, {schema, field_name}
192
end
193
rescue
194
_e in ArgumentError ->
195
raise InvalidFieldError, {schema, field_name}
196
end
197
end
198
end
changed lib/forage/codec/encoder.ex
 
@@ -1,90 1,90 @@
1
- defmodule Forage.Codec.Encoder do
2
- @moduledoc """
3
- Functionality to encode a `Forage.Plan` into a Phoenix `param` map
4
- for use with the `ApplicationWeb.Router.Helpers`.
5
- """
6
- alias Forage.ForagePlan
7
-
8
- @doc """
9
- Encodes a forage plan into a params map.
10
-
11
- This function doesn't need to take the schema as an argument
12
- because it will never have to convert a string into an atom
13
- (the params map contains only strings and never atoms)
14
- """
15
- def encode(%ForagePlan{} = plan) do
16
- # Each of the forage components (search, sort and pagination) will be encoded as maps,
17
- # so that they can simply be merged together
18
- search_map = encode_search(plan)
19
- sort_map = encode_sort(plan)
20
- pagination_map = encode_pagination(plan)
21
- # Merge the three maps
22
- search_map |> Map.merge(sort_map) |> Map.merge(pagination_map)
23
- end
24
-
25
- @doc """
26
- Encode the "search" part of a forage plan. Returns a map.
27
- """
28
- def encode_search(%ForagePlan{search: []} = _plan), do: %{}
29
-
30
- def encode_search(%ForagePlan{search: search} = _plan) do
31
- search_value =
32
- for search_filter <- search, into: %{} do
33
- field_name =
34
- case search_filter[:field] do
35
- {:simple, name} when is_atom(name) ->
36
- Atom.to_string(name)
37
-
38
- {:assoc, {_schema, local, remote}} when is_atom(local) and is_atom(remote) ->
39
- local_string = Atom.to_string(local)
40
- remote_string = Atom.to_string(remote)
41
- local_string <> "." <> remote_string
42
- end
43
-
44
- # Return key-value pair
45
- {field_name,
46
- %{
47
- "op" => search_filter[:operator],
48
- "val" => search_filter[:value]
49
- }}
50
- end
51
-
52
- %{"_search" => search_value}
53
- end
54
-
55
- @doc """
56
- Encode the "sort" part of a forage plan. Returns a map.
57
- """
58
- def encode_sort(%ForagePlan{sort: []} = _plan), do: %{}
59
-
60
- def encode_sort(%ForagePlan{sort: sort} = _plan) do
61
- sort_value =
62
- for sort_column <- sort, into: %{} do
63
- field_name = Atom.to_string(sort_column[:field])
64
- direction_name = Atom.to_string(sort_column[:direction])
65
- # Return key-value pair
66
- {field_name, %{"direction" => direction_name}}
67
- end
68
-
69
- %{"_sort" => sort_value}
70
- end
71
-
72
- @doc """
73
- Encode the "pagination" part of a forage plan. Returns a map.
74
- """
75
- def encode_pagination(%ForagePlan{pagination: pagination} = _plan) do
76
- encoded_after =
77
- case Keyword.fetch(pagination, :after) do
78
- :error -> %{}
79
- {:ok, value} -> %{"after" => value}
80
- end
81
-
82
- encoded_before =
83
- case Keyword.fetch(pagination, :before) do
84
- :error -> %{}
85
- {:ok, value} -> %{"before" => value}
86
- end
87
-
88
- Map.merge(encoded_after, encoded_before)
89
- end
90
- end
1
defmodule Forage.Codec.Encoder do
2
@moduledoc """
3
Functionality to encode a `Forage.Plan` into a Phoenix `param` map
4
for use with the `ApplicationWeb.Router.Helpers`.
5
"""
6
alias Forage.ForagePlan
7
8
@doc """
9
Encodes a forage plan into a params map.
10
11
This function doesn't need to take the schema as an argument
12
because it will never have to convert a string into an atom
13
(the params map contains only strings and never atoms)
14
"""
15
def encode(%ForagePlan{} = plan) do
16
# Each of the forage components (filter, sort and pagination) will be encoded as maps,
17
# so that they can simply be merged together
18
filter_map = encode_filter(plan)
19
sort_map = encode_sort(plan)
20
pagination_map = encode_pagination(plan)
21
# Merge the three maps
22
filter_map |> Map.merge(sort_map) |> Map.merge(pagination_map)
23
end
24
25
@doc """
26
Encode the "filter" part of a forage plan. Returns a map.
27
"""
28
def encode_filter(%ForagePlan{filter: []} = _plan), do: %{}
29
30
def encode_filter(%ForagePlan{filter: filter} = _plan) do
31
filter_value =
32
for filter_filter <- filter, into: %{} do
33
field_name =
34
case filter_filter[:field] do
35
{:simple, name} when is_atom(name) ->
36
Atom.to_string(name)
37
38
{:assoc, {_schema, local, remote}} when is_atom(local) and is_atom(remote) ->
39
local_string = Atom.to_string(local)
40
remote_string = Atom.to_string(remote)
41
local_string <> "." <> remote_string
42
end
43
44
# Return key-value pair
45
{field_name,
46
%{
47
"op" => filter_filter[:operator],
48
"val" => filter_filter[:value]
49
}}
50
end
51
52
%{"_filter" => filter_value}
53
end
54
55
@doc """
56
Encode the "sort" part of a forage plan. Returns a map.
57
"""
58
def encode_sort(%ForagePlan{sort: []} = _plan), do: %{}
59
60
def encode_sort(%ForagePlan{sort: sort} = _plan) do
61
sort_value =
62
for sort_column <- sort, into: %{} do
63
field_name = Atom.to_string(sort_column[:field])
64
direction_name = Atom.to_string(sort_column[:direction])
65
# Return key-value pair
66
{field_name, %{"direction" => direction_name}}
67
end
68
69
%{"_sort" => sort_value}
70
end
71
72
@doc """
73
Encode the "pagination" part of a forage plan. Returns a map.
74
"""
75
def encode_pagination(%ForagePlan{pagination: pagination} = _plan) do
76
encoded_after =
77
case Keyword.fetch(pagination, :after) do
78
:error -> %{}
79
{:ok, value} -> %{"after" => value}
80
end
81
82
encoded_before =
83
case Keyword.fetch(pagination, :before) do
84
:error -> %{}
85
{:ok, value} -> %{"before" => value}
86
end
87
88
Map.merge(encoded_after, encoded_before)
89
end
90
end
changed lib/forage/codec/exceptions/invalid_assoc_error.ex
 
@@ -1,16 1,16 @@
1
- defmodule Forage.Codec.Exceptions.InvalidAssocError do
2
- defexception [:message]
3
-
4
- @impl true
5
- def exception({schema, assoc_name}) do
6
- assoc_atoms = schema.__schema__(:associations)
7
- valid_assoc_names = Enum.map(assoc_atoms, &Atom.to_string/1)
8
-
9
- msg = """
10
- #{inspect(assoc_name)} is not valid for schema #{inspect(schema)}. \
11
- Valid field names are: #{inspect(valid_assoc_names)}.\
12
- """
13
-
14
- %__MODULE__{message: msg}
15
- end
16
- end
1
defmodule Forage.Codec.Exceptions.InvalidAssocError do
2
defexception [:message]
3
4
@impl true
5
def exception({schema, assoc_name}) do
6
assoc_atoms = schema.__schema__(:associations)
7
valid_assoc_names = Enum.map(assoc_atoms, &Atom.to_string/1)
8
9
msg = """
10
#{inspect(assoc_name)} is not valid for schema #{inspect(schema)}. \
11
Valid field names are: #{inspect(valid_assoc_names)}.\
12
"""
13
14
%__MODULE__{message: msg}
15
end
16
end
changed lib/forage/codec/exceptions/invalid_field_error.ex
 
@@ -1,16 1,16 @@
1
- defmodule Forage.Codec.Exceptions.InvalidFieldError do
2
- defexception [:message]
3
-
4
- @impl true
5
- def exception({schema, field_name}) do
6
- field_atoms = schema.__schema__(:fields)
7
- valid_field_names = Enum.map(field_atoms, &Atom.to_string/1)
8
-
9
- msg = """
10
- #{inspect(field_name)} is not valid for schema #{inspect(schema)}. \
11
- Valid field names are: #{inspect(valid_field_names)}.\
12
- """
13
-
14
- %__MODULE__{message: msg}
15
- end
16
- end
1
defmodule Forage.Codec.Exceptions.InvalidFieldError do
2
defexception [:message]
3
4
@impl true
5
def exception({schema, field_name}) do
6
field_atoms = schema.__schema__(:fields)
7
valid_field_names = Enum.map(field_atoms, &Atom.to_string/1)
8
9
msg = """
10
#{inspect(field_name)} is not valid for schema #{inspect(schema)}. \
11
Valid field names are: #{inspect(valid_field_names)}.\
12
"""
13
14
%__MODULE__{message: msg}
15
end
16
end
changed lib/forage/codec/exceptions/invalid_pagination_data_error.ex
 
@@ -1,12 1,12 @@
1
- defmodule Forage.Codec.Exceptions.InvalidPaginationDataError do
2
- defexception [:message]
3
-
4
- @impl true
5
- def exception(value) do
6
- msg = """
7
- Pagination data (page number or page size) must parse to an integer. \
8
- Got #{inspect value}.
9
- """
10
- %__MODULE__{message: msg}
11
- end
1
defmodule Forage.Codec.Exceptions.InvalidPaginationDataError do
2
defexception [:message]
3
4
@impl true
5
def exception(value) do
6
msg = """
7
Pagination data (page number or page size) must parse to an integer. \
8
Got #{inspect value}.
9
"""
10
%__MODULE__{message: msg}
11
end
12
12
end
\ No newline at end of file
changed lib/forage/codec/exceptions/invalid_sort_direction_error.ex
 
@@ -1,12 1,12 @@
1
- defmodule Forage.Codec.Exceptions.InvalidSortDirectionError do
2
- defexception [:message]
3
-
4
- @impl true
5
- def exception(direction) do
6
- msg = """
7
- #{inspect direction} is not a valid sorting direction. \
8
- Valid sort directions are: ["asc", "desc"].\
9
- """
10
- %__MODULE__{message: msg}
11
- end
1
defmodule Forage.Codec.Exceptions.InvalidSortDirectionError do
2
defexception [:message]
3
4
@impl true
5
def exception(direction) do
6
msg = """
7
#{inspect direction} is not a valid sorting direction. \
8
Valid sort directions are: ["asc", "desc"].\
9
"""
10
%__MODULE__{message: msg}
11
end
12
12
end
\ No newline at end of file
changed lib/forage/forage_plan.ex
 
@@ -1,16 1,21 @@
1
- defmodule Forage.ForagePlan do
2
- @moduledoc """
3
- A forage plan, which can be used to run paginated queries on your repo.
4
- """
5
- defstruct search: [],
6
- sort: [],
7
- pagination: []
8
-
9
- def new(opts) do
10
- %__MODULE__{
11
- search: Keyword.get(opts, :search, []),
12
- sort: Keyword.get(opts, :sort, []),
13
- pagination: Keyword.get(opts, :pagination, [])
14
- }
15
- end
16
- end
1
defmodule Forage.ForagePlan do
2
@moduledoc """
3
A forage plan, which can be used to run paginated queries on your repo.
4
5
It contains 3 parts:
6
* `:filter` - a list of filters for the plan.
7
They will be converted into Ecto `where` clauses
8
* `:sort` - a list of fields to use when sorting
9
They will be converted into Ecto `oerder_by` clauses.
10
* `:pagination` - data related to pagination of entries.
11
Forage uses [Paginator](https://github.com/duffelhq/paginator)
12
under the hood to implement
13
[cursor-based pagination](https://github.com/duffelhq/paginator#limit-offset),
14
which is more efficient than the naïve
15
[offset-based pagination](https://github.com/duffelhq/paginator#cursor-based-aka-keyset-pagination)
16
for medium/large datasets.
17
"""
18
defstruct filter: [],
19
sort: [],
20
pagination: []
21
end
changed lib/forage/paginator.ex
 
@@ -1,46 1,88 @@
1
- defmodule Forage.Paginator do
2
- alias Forage.QueryBuilder
3
-
4
- defp get_sort_direction!(forage_plan) do
5
- all_asc? = Enum.all?(forage_plan.sort, fn field_data -> field_data[:direction] == :asc end)
6
- all_desc? = Enum.all?(forage_plan.sort, fn field_data -> field_data[:direction] == :desc end)
7
- # TODO: better exceptions
8
- case {all_asc?, all_desc?} do
9
- {true, false} -> :asc
10
- {false, true} -> :desc
11
- {false, false} -> raise ArgumentError, "Sort fields must be all `:asc` or all `:desc`"
12
- {true, true} -> raise "This state is impossible unless there are no sort fields!"
13
- end
14
- end
15
-
16
- defp get_fields(forage_plan) do
17
- Enum.map(forage_plan.sort, fn field_data -> field_data[:field] end)
18
- end
19
-
20
- @doc """
21
- Build properly paginated Ecto queries from a set of parameters.
22
-
23
- Requires a repo with a `paginate/2` function.
24
- The easiest way of having a compliant repo is to `use Paginator, ...` inside your `Repo`.
25
- """
26
- def paginate(params, schema, repo, options, repo_opts \\ []) do
27
- # Get an initial query (before pagination)
28
- {forage_plan, query} = QueryBuilder.build_query(params, schema, options)
29
- # The cursor fields are the fields used to sort the query
30
- cursor_fields = get_fields(forage_plan)
31
- # The sort direction is identified from the Ecto query.
32
- # It's possible that the ecto query sorts the sort fields in different directions.
33
- # If that happens, raise an exception with extreme prejudice.
34
- sort_direction = get_sort_direction!(forage_plan)
35
- # Gather all pagination option in one place
36
- pagination_options =
37
- forage_plan.pagination
38
- [
39
- cursor_fields: cursor_fields,
40
- sort_direction: sort_direction
41
- ]
42
-
43
- # Finally, run the (paginated) query and return the data.
44
- repo.paginate(query, pagination_options repo_opts)
45
- end
46
- end
1
defmodule Forage.Paginator do
2
alias Forage.QueryBuilder
3
4
defp get_sort_direction!(forage_plan) do
5
all_asc? = Enum.all?(forage_plan.sort, fn field_data -> field_data[:direction] == :asc end)
6
all_desc? = Enum.all?(forage_plan.sort, fn field_data -> field_data[:direction] == :desc end)
7
# TODO: better exceptions
8
case {all_asc?, all_desc?} do
9
{true, false} -> :asc
10
{false, true} -> :desc
11
{false, false} -> raise ArgumentError, "Sort fields must be all `:asc` or all `:desc`"
12
{true, true} -> raise "This state is impossible unless there are no sort fields!"
13
end
14
end
15
16
defp get_fields(forage_plan) do
17
Enum.map(forage_plan.sort, fn field_data -> field_data[:field] end)
18
end
19
20
@doc """
21
Build properly paginated Ecto queries from a set of parameters.
22
23
Requires a repo with a `paginate/2` function.
24
The easiest way of having a compliant repo is to `use Paginator, ...` inside your `Repo`.
25
"""
26
def paginate(params, schema, repo, options, repo_opts \\ []) do
27
# Get an initial query (before pagination)
28
{forage_plan, query} = QueryBuilder.build_query(params, schema, options)
29
# The cursor fields are the fields used to sort the query
30
cursor_fields = get_fields(forage_plan)
31
32
pagination_limit =
33
case Keyword.fetch(options, :limit) do
34
{:ok, limit} -> [{:limit, limit}]
35
:error -> []
36
end
37
38
# The sort direction is identified from the Ecto query.
39
# It's possible that the ecto query sorts the sort fields in different directions.
40
# If that happens, raise an exception with extreme prejudice.
41
sort_direction = get_sort_direction!(forage_plan)
42
# Gather all pagination option in one place
43
pagination_options =
44
forage_plan.pagination
45
pagination_limit
46
[
47
cursor_fields: cursor_fields,
48
sort_direction: sort_direction
49
]
50
51
# Finally, run the (paginated) query and return the data.
52
repo.paginate(query, pagination_options repo_opts)
53
end
54
55
def pagination_options(params, schema, options \\ []) do
56
{pagination_options, _query} = pagination_options_and_query(params, schema, options)
57
# Return only what matters
58
pagination_options
59
end
60
61
defp pagination_options_and_query(params, schema, options) do
62
# Get an initial query (before pagination)
63
{forage_plan, query} = QueryBuilder.build_query(params, schema, options)
64
# The cursor fields are the fields used to sort the query
65
cursor_fields = get_fields(forage_plan)
66
67
pagination_limit =
68
case Keyword.fetch(options, :limit) do
69
{:ok, limit} -> [{:limit, limit}]
70
:error -> []
71
end
72
73
# The sort direction is identified from the Ecto query.
74
# It's possible that the ecto query sorts the sort fields in different directions.
75
# If that happens, raise an exception with extreme prejudice.
76
sort_direction = get_sort_direction!(forage_plan)
77
# Gather all pagination option in one place
78
pagination_options =
79
forage_plan.pagination
80
pagination_limit
81
[
82
cursor_fields: cursor_fields,
83
sort_direction: sort_direction
84
]
85
86
{pagination_options, query}
87
end
88
end
changed lib/forage/query_builder.ex
 
@@ -1,82 1,82 @@
1
- defmodule Forage.QueryBuilder do
2
- @moduledoc false
3
- import Ecto.Query, only: [from: 2]
4
- alias Forage.Codec.Decoder
5
- alias Forage.QueryBuilder.SearchFilter
6
- alias Forage.QueryBuilder.SortField
7
-
8
- defp sorts_by_id?(forage_plan) do
9
- Enum.find(forage_plan.sort, fn f -> f[:field] == :id end)
10
- end
11
-
12
- defp maybe_add_sort_fields(forage_plan, sort_fields, direction) do
13
- case forage_plan.sort do
14
- [] ->
15
- sort_data = Enum.map(sort_fields, fn field -> [field: field, direction: direction] end)
16
- %{forage_plan | sort: sort_data}
17
-
18
- [[field: _, direction: field_direction] | _rest] ->
19
- if sorts_by_id?(forage_plan) do
20
- forage_plan
21
- else
22
- sort = forage_plan.sort
23
- %{forage_plan | sort: sort [[field: :id, direction: field_direction]]}
24
- end
25
- end
26
- end
27
-
28
- def join_assocs(query, assocs) do
29
- Enum.reduce(assocs, query, fn {:assoc, {_, local, _}}, query_so_far ->
30
- from([p, ...] in query_so_far,
31
- join: x in assoc(p, ^local)
32
- )
33
- end)
34
- end
35
-
36
- @doc """
37
- Build a (non-paginated) query from `params`.
38
- """
39
- def build_query(params, schema, options \\ []) do
40
- # Process the raw Phoenix params into a form that can be more easily digested
41
- # by Forage and some other pagination library like scrivener
42
- raw_forage_plan = Decoder.decode(params, schema)
43
- # It's really important that there is a stable sort order.
44
- # If the `params` don't contain sort information, we try to extract
45
- # sort information from the `options`.
46
- default_sort = Keyword.get(options, :sort, [])
47
- default_sort_direction = Keyword.get(options, :sort_direction, :asc)
48
- preload = Keyword.get(options, :preload, [])
49
- # This plan has sort information, even if the `params` don't.
50
- forage_plan = maybe_add_sort_fields(raw_forage_plan, default_sort, default_sort_direction)
51
-
52
- # Build parts of the query (the filters and the sorting columns)
53
- {joins, where_clause} = SearchFilter.joins_and_where_clause(forage_plan.search)
54
- order_by_clause = SortField.build_order_by_clause(forage_plan.sort)
55
- # Build the query (except for the pagination)
56
- query_with_joins = join_assocs(schema, joins)
57
-
58
- final_query =
59
- from([...] in query_with_joins,
60
- where: ^where_clause,
61
- order_by: ^order_by_clause,
62
- preload: ^preload
63
- )
64
-
65
- # Return the forage plan and the query
66
- {forage_plan, final_query}
67
- end
68
-
69
- # Helpers
70
-
71
- @doc false
72
- def extract_non_empty_assocs(filters) do
73
- assocs =
74
- Enum.filter(filters, fn filter ->
75
- match?({:assoc, _assoc}, filter[:field])
76
- end)
77
-
78
- Enum.map(assocs, fn {:assoc, {_schema, local, remote}} ->
79
- {local, remote}
80
- end)
81
- end
82
- end
1
defmodule Forage.QueryBuilder do
2
@moduledoc false
3
import Ecto.Query, only: [from: 2]
4
alias Forage.Codec.Decoder
5
alias Forage.QueryBuilder.Filter
6
alias Forage.QueryBuilder.SortField
7
8
defp sorts_by_id?(forage_plan) do
9
Enum.find(forage_plan.sort, fn f -> f[:field] == :id end)
10
end
11
12
defp maybe_add_sort_fields(forage_plan, sort_fields, direction) do
13
case forage_plan.sort do
14
[] ->
15
sort_data = Enum.map(sort_fields, fn field -> [field: field, direction: direction] end)
16
%{forage_plan | sort: sort_data}
17
18
[[field: _, direction: field_direction] | _rest] ->
19
if sorts_by_id?(forage_plan) do
20
forage_plan
21
else
22
sort = forage_plan.sort
23
%{forage_plan | sort: sort [[field: :id, direction: field_direction]]}
24
end
25
end
26
end
27
28
def join_assocs(query, assocs) do
29
Enum.reduce(assocs, query, fn {:assoc, {_, local, _}}, query_so_far ->
30
from([p, ...] in query_so_far,
31
join: x in assoc(p, ^local)
32
)
33
end)
34
end
35
36
@doc """
37
Build a (non-paginated) query from `params`.
38
"""
39
def build_query(params, schema, options \\ []) do
40
# Process the raw Phoenix params into a form that can be more easily digested
41
# by Forage and some other pagination library like scrivener
42
raw_forage_plan = Decoder.decode(params, schema)
43
# It's really important that there is a stable sort order.
44
# If the `params` don't contain sort information, we try to extract
45
# sort information from the `options`.
46
default_sort = Keyword.get(options, :sort, [])
47
default_sort_direction = Keyword.get(options, :sort_direction, :asc)
48
preload = Keyword.get(options, :preload, [])
49
# This plan has sort information, even if the `params` don't.
50
forage_plan = maybe_add_sort_fields(raw_forage_plan, default_sort, default_sort_direction)
51
52
# Build parts of the query (the filters and the sorting columns)
53
{joins, where_clause} = Filter.joins_and_where_clause(forage_plan.filter)
54
order_by_clause = SortField.build_order_by_clause(forage_plan.sort)
55
# Build the query (except for the pagination)
56
query_with_joins = join_assocs(schema, joins)
57
58
final_query =
59
from([...] in query_with_joins,
60
where: ^where_clause,
61
order_by: ^order_by_clause,
62
preload: ^preload
63
)
64
65
# Return the forage plan and the query
66
{forage_plan, final_query}
67
end
68
69
# Helpers
70
71
@doc false
72
def extract_non_empty_assocs(filters) do
73
assocs =
74
Enum.filter(filters, fn filter ->
75
match?({:assoc, _assoc}, filter[:field])
76
end)
77
78
Enum.map(assocs, fn {:assoc, {_schema, local, remote}} ->
79
{local, remote}
80
end)
81
end
82
end
added lib/forage/query_builder/filter.ex
 
@@ -0,0 1,84 @@
1
defmodule Forage.QueryBuilder.Filter do
2
@moduledoc false
3
import Forage.QueryBuilder.Filter.AddFilterToQuery
4
5
@doc """
6
Compile a list of filters from a forage plan into an Ecto query
7
"""
8
def joins_and_where_clause(filters) do
9
assocs = extract_non_empty_assocs(filters)
10
simple_filters = extract_non_empty_simple_filters(filters)
11
join_fields = Enum.map(assocs, fn assoc -> assoc[:field] end)
12
nr_of_variables = length(assocs) 1
13
assoc_to_index = map_of_assoc_to_index(assocs)
14
15
query_without_assocs =
16
Enum.reduce(simple_filters, true, fn filter, query_so_far ->
17
{:simple, field} = filter[:field]
18
19
add_filter_to_query(
20
nr_of_variables,
21
# fields belong to the zeroth variable
22
0,
23
query_so_far,
24
filter[:operator],
25
field,
26
filter[:value]
27
)
28
end)
29
30
# Should we deprecate queries with assocs?
31
# Using foreign key columns is much, much simpler, and play better with the HTML widgets...
32
# I'm not even sure I understand this code right now...
33
# We will keep them for now.
34
query_with_assocs =
35
Enum.reduce(assocs, query_without_assocs, fn filter, query_so_far ->
36
{:assoc, {_schema, _local, remote} = assoc} = filter[:field]
37
variable_index = assoc_to_index[assoc]
38
39
add_filter_to_query(
40
nr_of_variables,
41
variable_index,
42
query_so_far,
43
filter[:operator],
44
remote,
45
filter[:value]
46
)
47
end)
48
49
{join_fields, query_with_assocs}
50
end
51
52
# Define the private function `add_filter_to_query/6`.
53
@spec add_filter_to_query(
54
n :: integer(),
55
i :: integer(),
56
query_so_far :: any(),
57
operator :: String.t(),
58
field :: atom(),
59
value :: any()
60
) :: any()
61
define_filter_adder(:add_filter_to_query, 8)
62
63
defp map_of_assoc_to_index(assocs) do
64
assocs
65
|> Enum.map(fn filter ->
66
{:assoc, assoc} = filter[:field]
67
assoc
68
end)
69
|> Enum.with_index(1)
70
|> Enum.into(%{})
71
end
72
73
defp extract_non_empty_assocs(filters) do
74
Enum.filter(filters, fn filter ->
75
match?({:assoc, _assoc}, filter[:field]) and filter[:value] != ""
76
end)
77
end
78
79
defp extract_non_empty_simple_filters(filters) do
80
Enum.filter(filters, fn filter ->
81
match?({:simple, _simple}, filter[:field]) and filter[:value] != ""
82
end)
83
end
84
end
added lib/forage/query_builder/filter/add_filter_to_query.ex
 
@@ -0,0 1,170 @@
1
defmodule Forage.QueryBuilder.Filter.AddFilterToQuery do
2
import Ecto.Query, only: [dynamic: 2]
3
4
def filter_adder_clauses(filter_adder, n, i) do
5
underscore_list = List.duplicate(quote(do: _), n - 1)
6
var = Macro.var(:x, __MODULE__)
7
variables_list = List.insert_at(underscore_list, i, var)
8
9
quote do
10
# There are two cases we must handle:
11
# 1. The previous fragment is a literal `true` atom
12
# 2. The previous fragment is already a non-trivial query
13
#
14
# Handling these two cases might be a little unnecessary,
15
# but it produces cleaner queries
16
#
17
# -------------------------
18
# There is a prior fragment
19
# -------------------------
20
# Generic
21
defp unquote(filter_adder)(unquote(n), unquote(i), true, "equal_to", field_atom, value) do
22
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) == ^value)
23
end
24
25
defp unquote(filter_adder)(unquote(n), unquote(i), true, "not_equal_to", field_atom, value) do
26
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) != ^value)
27
end
28
29
# Numeric
30
defp unquote(filter_adder)(unquote(n), unquote(i), true, "greater_than_or_equal_to", field_atom, value) do
31
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) >= ^value)
32
end
33
34
defp unquote(filter_adder)(unquote(n), unquote(i), true, "less_than_or_equal_to", field_atom, value) do
35
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) <= ^value)
36
end
37
38
defp unquote(filter_adder)(unquote(n), unquote(i), true, "greater_than", field_atom, value) do
39
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) > ^value)
40
end
41
42
defp unquote(filter_adder)(unquote(n), unquote(i), true, "less_than", field_atom, value) do
43
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) < ^value)
44
end
45
46
# Text
47
defp unquote(filter_adder)(unquote(n), unquote(i), true, "contains_ignore_accents", field_atom, value) do
48
escaped = Forage.QueryBuilder.Filter.AddFilterToQuery.escape_regex(value)
49
dynamic(
50
unquote(variables_list),
51
ilike(
52
field(unquote(var), ^field_atom),
53
fragment("('%' || forage_unaccent(?) || '%')", ^escaped)
54
)
55
)
56
end
57
58
defp unquote(filter_adder)(unquote(n), unquote(i), true, "contains", field_atom, value) do
59
text = "%" <> Forage.QueryBuilder.Filter.AddFilterToQuery.escape_regex(value) <> "%"
60
dynamic(unquote(variables_list), ilike(field(unquote(var), ^field_atom), ^text))
61
end
62
63
defp unquote(filter_adder)(unquote(n), unquote(i), true, "starts_with", field_atom, value) do
64
text = Forage.QueryBuilder.Filter.AddFilterToQuery.escape_regex(value) <> "%"
65
dynamic(unquote(variables_list), ilike(field(unquote(var), ^field_atom), ^text))
66
end
67
68
defp unquote(filter_adder)(unquote(n), unquote(i), true, "ends_with", field_atom, value) do
69
text = "%" <> Forage.QueryBuilder.Filter.AddFilterToQuery.escape_regex(value)
70
dynamic(unquote(variables_list), ilike(field(unquote(var), ^field_atom), ^text))
71
end
72
73
# No operator is given: assume "equal_to"
74
# TODO: should we raise an error?
75
defp unquote(filter_adder)(unquote(n), unquote(i), true, nil, field_atom, value) do
76
text = value
77
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) == ^text)
78
end
79
80
# --------------------------
81
# There is no prior fragment
82
# --------------------------
83
# Generic
84
defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "equal_to", field_atom, value) do
85
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) == ^value and ^fragment)
86
end
87
88
defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "not_equal_to", field_atom, value) do
89
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) != ^value and ^fragment)
90
end
91
92
# Numeric
93
defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "greater_than_or_equal_to", field_atom, value) do
94
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) >= ^value and ^fragment)
95
end
96
97
defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "less_than_or_equal_to", field_atom, value) do
98
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) <= ^value and ^fragment)
99
end
100
101
defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "greater_than", field_atom, value) do
102
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) > ^value and ^fragment)
103
end
104
105
defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "less_than", field_atom, value) do
106
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) < ^value and ^fragment)
107
end
108
109
# Text
110
defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "contains", field_atom, value) do
111
text = "%" <> Forage.QueryBuilder.Filter.AddFilterToQuery.escape_regex(value) <> "%"
112
dynamic(unquote(variables_list), ilike(field(unquote(var), ^field_atom), ^text) and ^fragment)
113
end
114
115
defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "contains_ignore_accents", field_atom, value) do
116
escaped = Forage.QueryBuilder.Filter.AddFilterToQuery.escape_regex(value)
117
dynamic(
118
unquote(variables_list),
119
ilike(
120
field(unquote(var), ^field_atom),
121
fragment("('%' || forage_unaccent(?) || '%')", ^escaped)
122
) and ^fragment
123
)
124
end
125
126
defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "starts_with", field_atom, value) do
127
text = Forage.QueryBuilder.Filter.AddFilterToQuery.escape_regex(value) <> "%"
128
dynamic(unquote(variables_list), ilike(field(unquote(var), ^field_atom), ^text) and ^fragment)
129
end
130
131
defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "ends_with", field_atom, value) do
132
text = "%" <> Forage.QueryBuilder.Filter.AddFilterToQuery.escape_regex(value)
133
dynamic(unquote(variables_list), ilike(field(unquote(var), ^field_atom), ^text) and ^fragment)
134
end
135
136
# No operator is given: assume "equal_to"
137
# TODO: should we raise an error?
138
defp unquote(filter_adder)(unquote(n), unquote(i), fragment, nil, field_atom, value) do
139
text = value
140
dynamic(unquote(variables_list), field(unquote(var), ^field_atom) == ^text and ^fragment)
141
end
142
end
143
end
144
145
@doc false
146
def escape_regex(regex_string) do
147
regex_string
148
|> String.replace("_", "\\_")
149
|> String.replace("%", "\\%")
150
end
151
152
@doc """
153
Defines a function that adds fields to a pre-existing Ecto query.
154
155
The function is named `filter_adder` (must be an atom) and will support
156
up to `n_max` variables in the query.
157
"""
158
defmacro define_filter_adder(filter_adder, n_max) do
159
clauses =
160
for n <- 1..n_max do
161
for i <- 0..(n - 1) do
162
filter_adder_clauses(filter_adder, n, i)
163
end
164
end
165
166
quote do
167
unquote_splicing(clauses)
168
end
169
end
170
end
removed lib/forage/query_builder/search_filter.ex
 
@@ -1,83 0,0 @@
1
- defmodule Forage.QueryBuilder.SearchFilter do
2
- @moduledoc false
3
- import Forage.QueryBuilder.SearchFilter.AddFilterToQuery
4
-
5
- @doc """
6
- Compile a list of filters forage plan into an Ecto query
7
- """
8
- def joins_and_where_clause(filters) do
9
- assocs = extract_non_empty_assocs(filters)
10
- simple_filters = extract_non_empty_simple_filters(filters)
11
- join_fields = Enum.map(assocs, fn assoc -> assoc[:field] end)
12
- nr_of_variables = length(assocs) 1
13
- assoc_to_index = map_of_assoc_to_index(assocs)
14
-
15
- query_without_assocs =
16
- Enum.reduce(simple_filters, true, fn filter, query_so_far ->
17
- {:simple, field} = filter[:field]
18
-
19
- add_filter_to_query(
20
- nr_of_variables,
21
- # fields belong to the zeroth variable
22
- 0,
23
- query_so_far,
24
- filter[:operator],
25
- field,
26
- filter[:value]
27
- )
28
- end)
29
-
30
- # Should we deprecate queries with assocs?
31
- # Using foreign key columns is much, much simpler, and play better with the HTML widgets...
32
- # We will keep them for now.
33
- query_with_assocs =
34
- Enum.reduce(assocs, query_without_assocs, fn filter, query_so_far ->
35
- {:assoc, {_schema, _local, remote} = assoc} = filter[:field]
36
- variable_index = assoc_to_index[assoc]
37
-
38
- add_filter_to_query(
39
- nr_of_variables,
40
- variable_index,
41
- query_so_far,
42
- filter[:operator],
43
- remote,
44
- filter[:value]
45
- )
46
- end)
47
-
48
- {join_fields, query_with_assocs}
49
- end
50
-
51
- # Define the private function `add_filter_to_query/6`.
52
- @spec add_filter_to_query(
53
- n :: integer(),
54
- i :: integer(),
55
- query_so_far :: any(),
56
- operator :: String.t(),
57
- field :: atom(),
58
- value :: any()
59
- ) :: any()
60
- define_filter_adder(:add_filter_to_query, 8)
61
-
62
- defp map_of_assoc_to_index(assocs) do
63
- assocs
64
- |> Enum.map(fn filter ->
65
- {:assoc, assoc} = filter[:field]
66
- assoc
67
- end)
68
- |> Enum.with_index(1)
69
- |> Enum.into(%{})
70
- end
71
-
72
- defp extract_non_empty_assocs(filters) do
73
- Enum.filter(filters, fn filter ->
74
- match?({:assoc, _assoc}, filter[:field]) and filter[:value] != ""
75
- end)
76
- end
77
-
78
- defp extract_non_empty_simple_filters(filters) do
79
- Enum.filter(filters, fn filter ->
80
- match?({:simple, _simple}, filter[:field]) and filter[:value] != ""
81
- end)
82
- end
83
- end
removed lib/forage/query_builder/search_filter/add_filter_to_query.ex
 
@@ -1,142 0,0 @@
1
- defmodule Forage.QueryBuilder.SearchFilter.AddFilterToQuery do
2
- import Ecto.Query, only: [dynamic: 2]
3
-
4
- def filter_adder_clauses(filter_adder, n, i) do
5
- underscore_list = List.duplicate(quote(do: _), n - 1)
6
- var = Macro.var(:x, __MODULE__)
7
- variables_list = List.insert_at(underscore_list, i, var)
8
-
9
- quote do
10
- # There are two cases we must handle:
11
- # 1. The previous fragment is a literal `true` atom
12
- # 2. The previous fragment is already a non-trivial query
13
- #
14
- # Handling these two cases might be a little unnecessary,
15
- # but it produces cleaner queries
16
- #
17
- # -------------------------
18
- # There is a prior fragment
19
- # -------------------------
20
- # Generic
21
- defp unquote(filter_adder)(unquote(n), unquote(i), true, "equal_to", field_atom, value) do
22
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) == ^value)
23
- end
24
-
25
- defp unquote(filter_adder)(unquote(n), unquote(i), true, "not_equal_to", field_atom, value) do
26
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) != ^value)
27
- end
28
-
29
- # Numeric
30
- defp unquote(filter_adder)(unquote(n), unquote(i), true, "greater_than_or_equal_to", field_atom, value) do
31
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) >= ^value)
32
- end
33
-
34
- defp unquote(filter_adder)(unquote(n), unquote(i), true, "less_than_or_equal_to", field_atom, value) do
35
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) <= ^value)
36
- end
37
-
38
- defp unquote(filter_adder)(unquote(n), unquote(i), true, "greater_than", field_atom, value) do
39
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) > ^value)
40
- end
41
-
42
- defp unquote(filter_adder)(unquote(n), unquote(i), true, "less_than", field_atom, value) do
43
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) < ^value)
44
- end
45
-
46
- # Text
47
- defp unquote(filter_adder)(unquote(n), unquote(i), true, "contains", field_atom, value) do
48
- text = "%" <> value <> "%"
49
- dynamic(unquote(variables_list), ilike(field(unquote(var), ^field_atom), ^text))
50
- end
51
-
52
- defp unquote(filter_adder)(unquote(n), unquote(i), true, "starts_with", field_atom, value) do
53
- text = value <> "%"
54
- dynamic(unquote(variables_list), ilike(field(unquote(var), ^field_atom), ^text))
55
- end
56
-
57
- defp unquote(filter_adder)(unquote(n), unquote(i), true, "ends_with", field_atom, value) do
58
- text = "%" <> value
59
- dynamic(unquote(variables_list), ilike(field(unquote(var), ^field_atom), ^text))
60
- end
61
-
62
- # No operator is given: assume "equal_to"
63
- # TODO: should we raise an error?
64
- defp unquote(filter_adder)(unquote(n), unquote(i), true, nil, field_atom, value) do
65
- text = value
66
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) == ^text)
67
- end
68
-
69
- # --------------------------
70
- # There is no prior fragment
71
- # --------------------------
72
- # Generic
73
- defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "equal_to", field_atom, value) do
74
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) == ^value and ^fragment)
75
- end
76
-
77
- defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "not_equal_to", field_atom, value) do
78
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) != ^value and ^fragment)
79
- end
80
-
81
- # Numeric
82
- defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "greater_than_or_equal_to", field_atom, value) do
83
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) >= ^value and ^fragment)
84
- end
85
-
86
- defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "less_than_or_equal_to", field_atom, value) do
87
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) <= ^value and ^fragment)
88
- end
89
-
90
- defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "greater_than", field_atom, value) do
91
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) > ^value and ^fragment)
92
- end
93
-
94
- defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "less_than", field_atom, value) do
95
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) < ^value and ^fragment)
96
- end
97
-
98
- # Text
99
- defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "contains", field_atom, value) do
100
- text = "%" <> value <> "%"
101
- dynamic(unquote(variables_list), ilike(field(unquote(var), ^field_atom), ^text) and ^fragment)
102
- end
103
-
104
- defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "starts_with", field_atom, value) do
105
- text = value <> "%"
106
- dynamic(unquote(variables_list), ilike(field(unquote(var), ^field_atom), ^text) and ^fragment)
107
- end
108
-
109
- defp unquote(filter_adder)(unquote(n), unquote(i), fragment, "ends_with", field_atom, value) do
110
- text = "%" <> value
111
- dynamic(unquote(variables_list), ilike(field(unquote(var), ^field_atom), ^text) and ^fragment)
112
- end
113
-
114
- # No operator is given: assume "equal_to"
115
- # TODO: should we raise an error?
116
- defp unquote(filter_adder)(unquote(n), unquote(i), fragment, nil, field_atom, value) do
117
- text = value
118
- dynamic(unquote(variables_list), field(unquote(var), ^field_atom) == ^text and ^fragment)
119
- end
120
- end
121
- end
122
-
123
- @doc """
124
- Defines a function that adds fields to a pre-existing Ecto query.
125
-
126
- The function is named `filter_adder` (must be an atom) and will support
127
- up to `n_max` variables in the query.
128
- """
129
- defmacro define_filter_adder(filter_adder, n_max) do
130
- clauses =
131
- for n <- 1..n_max do
132
- for i <- 0..(n - 1) do
133
- filter_adder_clauses(filter_adder, n, i)
134
- end
135
- end
136
-
137
- quote do
138
- unquote_splicing(clauses)
139
- end
140
- end
141
- end
142
-
changed lib/forage/query_builder/sort_field.ex
 
@@ -1,16 1,16 @@
1
- defmodule Forage.QueryBuilder.SortField do
2
- @moduledoc false
3
-
4
- def build_order_by_clause(sort_data) do
5
- # Return a keyword list
6
- for row <- sort_data do
7
- # May not exist if the user hasn't specified it.
8
- # By default, sort results in ascending order
9
- direction = row[:direction] || :asc
10
- # Will always existe becuase of how the keyword list is constructed
11
- field = row[:field]
12
- # Return the pair
13
- {direction, field}
14
- end
15
- end
1
defmodule Forage.QueryBuilder.SortField do
2
@moduledoc false
3
4
def build_order_by_clause(sort_data) do
5
# Return a keyword list
6
for row <- sort_data do
7
# May not exist if the user hasn't specified it.
8
# By default, sort results in ascending order
9
direction = row[:direction] || :asc
10
# Will always existe becuase of how the keyword list is constructed
11
field = row[:field]
12
# Return the pair
13
{direction, field}
14
end
15
end
16
16
end
\ No newline at end of file
added lib/forage/search.ex
 
@@ -0,0 1,164 @@
1
defmodule Forage.Search do
2
@moduledoc """
3
Utilities to help with search
4
"""
5
6
alias Ecto.Migration
7
require ExUnit.Assertions, as: Assertions
8
9
def define_forage_unaccent() do
10
sql_unaccent_up = "CREATE EXTENSION IF NOT EXISTS unaccent;"
11
sql_pg_trgm_up = "CREATE EXTENSION IF NOT EXISTS pg_trgm;"
12
sql_define_function_up = """
13
CREATE OR REPLACE FUNCTION public.forage_unaccent(text)
14
RETURNS text AS
15
$func$
16
SELECT public.unaccent('public.unaccent', $1)
17
$func$
18
19
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT;
20
"""
21
22
sql_unaccent_down = "DROP EXTENSION unaccent;"
23
sql_pg_trgm_down = "DROP EXTENSION pg_trgm;"
24
sql_define_function_down = "DROP FUNCTION public.forage_unaccent(text);"
25
26
Migration.execute(sql_unaccent_up, sql_unaccent_down)
27
Migration.execute(sql_pg_trgm_up, sql_pg_trgm_down)
28
Migration.execute(sql_define_function_up, sql_define_function_down)
29
end
30
31
defp make_column_builder(columns) do
32
coalesced = Enum.map(columns, fn name -> "coalesce(#{name}, '')" end)
33
concatenated = Enum.intersperse(coalesced, " || ' ' || ")
34
"(forage_unaccent(#{concatenated}))"
35
end
36
37
@doc """
38
Adds a trigram index for the given `column` in the given `table`.
39
40
This function is meant to be used in a migration file.
41
42
## Example
43
44
*TODO*
45
"""
46
def add_trigram_index(table, column) do
47
# SQL statements to create and drop the index
48
sql_index_up = """
49
CREATE INDEX #{column}_idx ON #{table}
50
USING GIN (#{column} gin_trgm_ops);
51
"""
52
53
sql_index_down = """
54
DROP INDEX #{column}_idx;
55
"""
56
57
Migration.execute(sql_index_up, sql_index_down)
58
end
59
60
@doc """
61
Adds a new column (named `column_name`) to the `table`.
62
The new column will be a concatenation of the `columns`.
63
The new column will be updated whenever any of the `columns` changes.
64
65
This function is meant to be used in a migration file.
66
67
To be able to use this functions you must have already defined the
68
`forage_unaccent()` PostgreSQL function.
69
The reason why we can't use `unaccent` directly is quite obscure;
70
you can read more aboute it [here](#)
71
72
## Example
73
74
*TODO*
75
"""
76
def add_unnaccented_search_column(table, column_name, columns) do
77
# Validate arguments:
78
# - :table must be an atom
79
Assertions.assert(is_atom(table))
80
# - :columns must be a list of atoms
81
Assertions.assert(is_list(columns))
82
Assertions.assert(Enum.all?(columns, fn col -> is_atom(col) end))
83
84
column_builder = make_column_builder(columns)
85
86
# SQL statements to create and drop the column
87
sql_column_up = """
88
ALTER TABLE #{table}
89
ADD COLUMN #{column_name} text
90
GENERATED ALWAYS AS #{column_builder} STORED;
91
"""
92
93
sql_column_down = """
94
ALTER TABLE #{table}
95
DROP COLUMN #{column_name};
96
"""
97
98
Migration.execute(sql_column_up, sql_column_down)
99
end
100
101
def add_unaccented_search_column_and_index(table, search_column, columns) do
102
add_unnaccented_search_column(table, search_column, columns)
103
add_trigram_index(table, search_column)
104
end
105
106
@doc """
107
Convert a search term into params that can be used in a forage plan.
108
Search will be case-insensitive.
109
110
This is implemented internally as an `ILIKE` operator.
111
By default, the `ILIKE` operator doesn't ignore accents.
112
If you want to ignore accents, you need to use `unaccented_search_params/2`
113
and prepare a special column in your database (see [here](#)).
114
115
If it makes sense for your application, you can implement
116
better search yourself.
117
"""
118
def naive_search_params(%{"_search" => term} = params, field) do
119
field_string = to_string(field)
120
map_with_new_filter = %{
121
field_string => %{
122
"op" => "contains",
123
"val" => term
124
}
125
}
126
127
replace_search_by_a_filter(params, map_with_new_filter)
128
end
129
130
@doc """
131
Convert a search term into params that can be used in a forage plan.
132
Search will be case-insensitive and will ignore accents.
133
134
*Currently this function requires PostgreSQL*.
135
136
This is implemented internally as an `ILIKE` operator.
137
Thus function calls the special `forage_unaccent()` function,
138
which you should have defined somehwere in your PostgreSQL database.
139
140
If it makes sense for your application, you can implement
141
better search yourself.
142
"""
143
def unaccented_search_params(%{"_search" => term} = params, field) do
144
field_string = to_string(field)
145
map_with_new_filter = %{
146
field_string => %{
147
"op" => "contains_ignore_accents",
148
"val" => term
149
}
150
}
151
152
replace_search_by_a_filter(params, map_with_new_filter)
153
end
154
155
defp replace_search_by_a_filter(params, map_with_new_filter) do
156
params
157
|> Map.delete("_search")
158
|> Map.update(
159
"_filter",
160
map_with_new_filter,
161
fn filters -> Map.merge(filters, map_with_new_filter) end
162
)
163
end
164
end
changed lib/forage_web/assets.ex
 
@@ -1,29 1,44 @@
1
- defmodule ForageWeb.Assets do
2
- @activate_select2 "<script>" <> File.read!("lib/forage_web/assets/select2.js") <> "</script>"
3
- @activate_datepicker "<script>" <>
4
- File.read!("lib/forage_web/assets/datepicker.js") <> "</script>"
5
- @forage_date_input_assets """
6
- <link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datepicker/1.9.0/css/bootstrap-datepicker.min.css" rel="stylesheet"/>
7
- <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datepicker/1.9.0/js/bootstrap-datepicker.min.js"></script>
8
- """
9
- @forage_select_assets """
10
- <link href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.6-rc.0/css/select2.min.css" rel="stylesheet" />
11
- <script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.6-rc.0/js/select2.min.js"></script>
12
- """
13
-
14
- def forage_date_input_assets() do
15
- {:safe, @forage_date_input_assets}
16
- end
17
-
18
- def forage_select_assets() do
19
- {:safe, @forage_select_assets}
20
- end
21
-
22
- def activate_forage_date_input() do
23
- {:safe, @activate_datepicker}
24
- end
25
-
26
- def activate_forage_select() do
27
- {:safe, @activate_select2}
28
- end
29
- end
1
defmodule ForageWeb.Assets do
2
@external_resource "lib/forage_web/assets/select2.js"
3
@external_resource "lib/forage_web/assets/datepicker.js"
4
5
@activate_select2 "<script>" <> File.read!("lib/forage_web/assets/select2.js") <> "</script>"
6
@activate_datepicker "<script>" <>
7
File.read!("lib/forage_web/assets/datepicker.js") <> "</script>"
8
@forage_date_input_assets """
9
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datepicker/1.9.0/css/bootstrap-datepicker.min.css" rel="stylesheet"/>
10
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datepicker/1.9.0/js/bootstrap-datepicker.min.js" defer></script>
11
"""
12
@forage_select_assets """
13
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.min.css"/>
14
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ttskch/select2-bootstrap4-theme/dist/select2-bootstrap4.min.css"/>
15
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
16
<script src="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.min.js"></script>
17
"""
18
19
def forage_date_input_assets(opts \\ []) do
20
languages = Keyword.get(opts, :languages, [])
21
locales_to_include =
22
for language <- languages do
23
[
24
~s[<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-datepicker/1.9.0/locales/bootstrap-datepicker.],
25
Atom.to_string(language),
26
~s[.min.js"></script>\n]
27
]
28
end
29
30
{:safe, [@forage_date_input_assets, "\n", locales_to_include]}
31
end
32
33
def forage_select_assets() do
34
{:safe, @forage_select_assets}
35
end
36
37
def activate_forage_date_input() do
38
{:safe, @activate_datepicker}
39
end
40
41
def activate_forage_select() do
42
{:safe, @activate_select2}
43
end
44
end
changed lib/forage_web/assets/datepicker.js
 
@@ -1,3 1,3 @@
1
- $('[data-forage-datepicker-widget="true"]').datepicker({
2
- format: 'yyyy-mm-dd'
1
$('[data-forage-datepicker-widget="true"]').datepicker({
2
format: 'yyyy-mm-dd'
3
3
});
\ No newline at end of file
changed lib/forage_web/assets/select2.js
 
@@ -1,55 1,55 @@
1
- $(document).ready(function () {
2
- $('[data-forage-select2-widget="true"]').each(
3
- function (index, node) {
4
- // Extract configuration options from the HTML element
5
- var baseUrl = node.dataset.url;
6
- var field = node.dataset.field;
7
- // Next, we create some extra storage to help us convert offset-based pagination
8
- // into cursor-based pagination.
9
- //
10
- // With offset-based pagination, the pages are numbered (i.e. 1, 2, 3...).
11
- // On the other hand, with cursor-based pagination, we have only an abstract token,
12
- // which serves as a link to the next page.
13
- //
14
- // To convert page numbers to tokens, we simply define a map that stores the token
15
- // for every page number.
16
- // That way, Select2 can continue to ask for page number 1, 2, 3, etc. because we
17
- // know how to convert the page number into out tokens
18
- var lastTerm = "";
19
- var pageToAfterToken = { 1: null };
20
- // Turn the node into a select2 box
21
- $(node).select2({
22
- // Use AJAX to get JSON data from the server
23
- ajax: {
24
- url: baseUrl,
25
- data: function (params) {
26
- var page = params.page || 1;
27
- var term = params.term || "";
28
- // Build the query
29
- var query = {};
30
- query["_search[" field "][op]"] = "contains";
31
- query["_search[" field "][val]"] = term;
32
- // If we don't have a token or if the query string has changed,
33
- // we don't want to reuse the pagination token.
34
- var afterToken = pageToAfterToken[page];
35
- if (afterToken && lastTerm == term) {
36
- query["_pagination[after]"] = afterToken;
37
- }
38
-
39
- // Query parameters will be ?search=[term]&type=public
40
- return query;
41
- },
42
- processResults: function (data, params) {
43
- var page = params.page || 1
44
- lastTerm = params.term || "";
45
- // With the request for the current page we also receive
46
- // the token for the next page
47
- pageToAfterToken[page 1] = data.pagination.after;
48
- return data;
49
- },
50
- dataType: 'json',
51
- placeholder: "..."
52
- }
53
- });
54
- })
1
$(document).ready(function () {
2
$('[data-forage-select2-widget="true"]').each(
3
function (_index, node) {
4
// Extract configuration options from the HTML element
5
var baseUrl = node.dataset.url;
6
7
// Next, we create some extra storage to help us convert offset-based pagination
8
// into cursor-based pagination.
9
//
10
// With offset-based pagination, the pages are numbered (i.e. 1, 2, 3...).
11
// On the other hand, with cursor-based pagination, we have only an abstract token,
12
// which serves as a link to the next page.
13
//
14
// To convert page numbers to tokens, we simply define a map that stores the token
15
// for every page number.
16
// That way, Select2 can continue to ask for page number 1, 2, 3, etc. because we
17
// know how to convert the page number into our tokens
18
var lastTerm = "";
19
var pageToAfterToken = { 1: null };
20
// Turn the node into a select2 box
21
var _select2 = $(node).select2({
22
theme: 'bootstrap4',
23
// Use AJAX to get JSON data from the server
24
ajax: {
25
url: baseUrl,
26
data: function (params) {
27
var page = params.page || 1;
28
var term = params.term || "";
29
// Build the query
30
var query = {};
31
query["_search"] = term;
32
// If we don't have a token or if the query string has changed,
33
// we don't want to reuse the pagination token.
34
var afterToken = pageToAfterToken[page];
35
if (afterToken && lastTerm == term) {
36
query["_pagination[after]"] = afterToken;
37
}
38
39
return query;
40
},
41
processResults: function (data, params) {
42
var page = params.page || 1
43
lastTerm = params.term || "";
44
// With the request for the current page we also receive
45
// the token for the next page
46
pageToAfterToken[page 1] = data.pagination.after;
47
return data;
48
},
49
dataType: 'json'
50
},
51
placeholder: "",
52
allowClear: true
53
});
54
})
55
55
});
\ No newline at end of file
changed lib/forage_web/display.ex
 
@@ -1,6 1,10 @@
1
- defprotocol ForageWeb.Display do
2
- @doc """
3
- Displays the model
4
- """
5
- def display(model)
6
- end
1
defprotocol ForageWeb.Display do
2
@doc """
3
Displays the model as plaintext.
4
5
This is meant to be used to diplay options in a select widget and
6
when you want to show the user a resource as HTML.
7
"""
8
def as_text(model)
9
def as_html(model)
10
end
changed lib/forage_web/forage_controller.ex
 
@@ -1,42 1,70 @@
1
- defmodule ForageWeb.ForageController do
2
- @moduledoc """
3
- Helper functions for Plug controllers that use forage.
4
- """
5
- alias ForageWeb.Display
6
-
7
- @doc """
8
- Renders paginated data into a shape that the select widget expects.
9
-
10
- This function returns a map.
11
- The user must use the JSON encoder in the Phoenix application to generate a JSON response.
12
-
13
- It would be more succint to return JSON directly from this function,
14
- but Forage has no way of invoking the application's JSON encoder,
15
- so we leave that responsibility to the user.
16
-
17
- ## Example:
18
-
19
- TODO
20
- """
21
- def forage_select_data(paginated) do
22
- results =
23
- for entry <- paginated.entries do
24
- %{text: Display.display(entry), id: entry.id}
25
- end
26
-
27
- %{
28
- results: results,
29
- pagination: %{
30
- more: paginated.metadata.after != nil,
31
- after: paginated.metadata.after
32
- }
33
- }
34
- end
35
-
36
- @doc """
37
- Extracts the pagination data from the request `params`
38
- """
39
- def pagination_from_params(params) do
40
- Map.take(params, ["_pagination"])
41
- end
42
- end
1
defmodule ForageWeb.ForageController do
2
@moduledoc """
3
Helper functions for Plug controllers that use forage.
4
"""
5
alias ForageWeb.Display
6
7
@doc """
8
Renders paginated data into a shape that the select widget expects.
9
10
This function returns a map.
11
The user must use the JSON encoder in the Phoenix application to generate a JSON response.
12
13
It would be more succint to return JSON directly from this function,
14
but Forage has no way of invoking the application's JSON encoder,
15
so we leave that responsibility to the user.
16
17
## Examples:
18
19
TODO
20
"""
21
def forage_select_data(paginated) do
22
results =
23
for entry <- paginated.entries do
24
%{text: Display.as_text(entry), id: Map.fetch!(entry, :id)}
25
end
26
27
%{
28
results: results,
29
pagination: %{
30
more: paginated.metadata.after != nil,
31
after: paginated.metadata.after
32
}
33
}
34
end
35
36
def forage_select_data(paginated, text_field) do
37
results =
38
for entry <- paginated.entries do
39
%{text: Map.fetch!(entry, text_field), id: Map.fetch!(entry, :id)}
40
end
41
42
%{
43
results: results,
44
pagination: %{
45
more: paginated.metadata.after != nil,
46
after: paginated.metadata.after
47
}
48
}
49
end
50
51
def forage_select_data_without_pagination(entries) do
52
results =
53
for entry <- entries do
54
%{text: Display.as_text(entry), id: entry.id}
55
end
56
57
%{results: results, pagination: %{}}
58
end
59
60
def forage_select_maps(maps) do
61
%{results: maps, pagination: %{}}
62
end
63
64
@doc """
65
Extracts the pagination data from the request `params`
66
"""
67
def pagination_from_params(params) do
68
Map.take(params, ["_pagination"])
69
end
70
end
changed lib/forage_web/forage_view.ex
 
@@ -1,45 1,84 @@
1
1
defmodule ForageWeb.ForageView do
2
2
@moduledoc """
3
- Helper functions for veiws that feature forage filters, pagination buttons or sort links.
3
Helper functions for vews that feature forage filters, pagination buttons or sort links.
4
4
"""
5
- import Phoenix.HTML, only: [sigil_e: 2, html_escape: 1]
5
import Phoenix.HTML, only: [sigil_e: 2]
6
6
import Phoenix.HTML.Link, only: [link: 2]
7
7
import Phoenix.HTML.Form, only: [input_value: 2, form_for: 4]
8
- alias Phoenix.HTML.Form
9
- alias Phoenix.HTML.FormData
10
- alias Phoenix.HTML.Tag
8
require Logger
9
alias Phoenix.HTML.{Form, FormData}
11
10
alias ForageWeb.Naming
12
- alias Ecto.Association.NotLoaded
13
11
alias ForageWeb.Display
12
alias Ecto.Association.NotLoaded
14
13
15
14
@doc """
15
Imports functions from `ForageWeb.ForageView` and defines a number of functions
16
specialized for the given resource.
16
17
18
TODO: complete this.
17
19
"""
18
20
defmacro __using__(options) do
21
caller_module = __CALLER__.module
22
19
23
routes_module =
20
24
case Keyword.fetch(options, :routes_module) do
21
- {:ok, module} -> module
22
- :error -> raise ArgumentError, "Requires a `:routes_module`."
25
# Atoms are represented as themselves in the AST
26
{:ok, module} ->
27
module
28
29
:error ->
30
raise ArgumentError, "Requires a `:routes_module`."
23
31
end
24
32
25
33
prefix =
26
34
case Keyword.fetch(options, :prefix) do
27
- {:ok, val} -> val
35
{:ok, val} when is_atom(val) -> val
28
36
:error -> raise ArgumentError, "Requires a `:prefix`."
29
37
end
30
38
31
- resource_path_fun_name = String.to_atom("#{prefix}_path")
32
- pagination_widget_fun_name = String.to_atom("#{prefix}_pagination_widget")
33
- sort_link_fun_name = String.to_atom("#{prefix}_sort_link")
34
- search_form_for_fun_name = String.to_atom("#{prefix}_search_form_for")
39
maybe_internationalized_forage_widgets =
40
case Keyword.fetch(options, :error_helpers_module) do
41
:error ->
42
Logger.warn(fn -> """
43
No `:error_helpers_module` was specified in the `use #{caller_module}, ...` call.
44
This way, Forage can't generate the specialized helpers.
45
If you don't want to generate the helpers, explicitly pass `nil` as an argument:
46
`use #{caller_module}, error_helpers_module: nil`
47
"""
48
end)
49
50
nil
51
52
# The user has explicitly given `nil` as the value for the `:error_helpers_module` option.
53
# Everything's ok, don't log a warning.
54
{:ok, nil} ->
55
nil
56
57
{:ok, error_helpers_module} ->
58
internationalized_forage_widgets(error_helpers_module)
59
end
60
61
prefixed_widgets = prefixed_forage_widgets(routes_module, prefix)
35
62
36
63
quote do
37
64
import ForageWeb.ForageView
38
65
39
- def unquote(search_form_for_fun_name)(conn, options \\ [], fun) do
66
unquote(maybe_internationalized_forage_widgets)
67
unquote(prefixed_widgets)
68
end
69
end
70
71
defp prefixed_forage_widgets(routes_module, prefix) do
72
resource_path_fun_name = String.to_atom("#{prefix}_path")
73
pagination_widget_fun_name = String.to_atom("#{prefix}_pagination_widget")
74
sort_link_fun_name = String.to_atom("#{prefix}_sort_link")
75
filter_form_for_fun_name = String.to_atom("#{prefix}_filter_form_for")
76
77
quote do
78
def unquote(filter_form_for_fun_name)(conn, options \\ [], fun) do
40
79
action = unquote(routes_module).unquote(resource_path_fun_name)(conn, :index)
41
80
42
- forage_search_form_for(
81
forage_filter_form_for(
43
82
conn,
44
83
action,
45
84
options,
 
@@ -70,118 109,356 @@ defmodule ForageWeb.ForageView do
70
109
end
71
110
end
72
111
112
defp internationalized_forage_widgets(error_helpers) do
113
forage_form_group_docs =
114
internationalization_aware_forage_widgets(error_helpers, :forage_form_group, 5)
115
116
forage_form_check_docs =
117
internationalization_aware_forage_widgets(error_helpers, :forage_form_check, 5)
118
119
forage_inline_form_check_docs =
120
internationalization_aware_forage_widgets(error_helpers, :forage_inline_form_check, 5)
121
122
quote do
123
@doc unquote(forage_form_group_docs)
124
def forage_form_group(form_data, field, label, input_fun) do
125
ForageWeb.ForageView.forage_form_group(
126
form_data,
127
field,
128
label,
129
unquote(error_helpers),
130
input_fun
131
)
132
end
133
134
@doc unquote(forage_form_check_docs)
135
def forage_form_check(form_data, field, label, input_fun) do
136
ForageWeb.ForageView.forage_form_check(
137
form_data,
138
field,
139
label,
140
unquote(error_helpers),
141
input_fun
142
)
143
end
144
145
@doc unquote(forage_inline_form_check_docs)
146
def forage_inline_form_check(form_data, field, label, input_fun) do
147
ForageWeb.ForageView.forage_inline_form_check(
148
form_data,
149
field,
150
label,
151
unquote(error_helpers),
152
input_fun
153
)
154
end
155
end
156
end
157
158
defp internationalization_aware_forage_widgets(error_helpers, name, arity) do
159
"""
160
Specialized version of `ForageWeb.ForageView.#{name}/#{arity}`
161
that uses the application's error helpers module (`#{inspect(error_helpers)}`)
162
for internationalization.
163
"""
164
end
165
166
def forage_error_tag(form, field, error_helpers) do
167
Enum.map(Keyword.get_values(form.errors, field), fn error ->
168
~e"""
169
<div class="invalid-feedback"><%= error_helpers.translate_error(error) %></div>
170
"""
171
end)
172
end
173
174
def forage_form_check(form, field, label, error_helpers, input_fun) do
175
forage_generic_form_check(form, field, label, error_helpers, false, input_fun)
176
end
177
178
def forage_inline_form_check(form, field, label, error_helpers, input_fun) do
179
forage_generic_form_check(form, field, label, error_helpers, true, input_fun)
180
end
181
182
defp forage_generic_form_check(form, field, label, error_helpers, inline?, input_fun) do
183
outer_div_class = (inline? && "form-check form-check-inline") || "form-check"
184
185
~e"""
186
<div class="<%= outer_div_class %>">
187
<%= input_fun.(form, field) %>
188
<%= Form.label form, field, label, class: "form-check-label" %>
189
<%= forage_error_tag(form, field, error_helpers) %>
190
</div>
191
"""
192
end
193
194
def forage_form_group(form, field, label, error_helpers, input_fun) do
195
~e"""
196
<div class="form-group">
197
<%= Form.label form, field, label, class: "control-label" %>
198
<%= input_fun.(form, field) %>
199
<%= forage_error_tag(form, field, error_helpers) %>
200
</div>
201
"""
202
end
203
204
def forage_row(widgets) do
205
[
206
~e[<div class="row">],
207
Enum.map(widgets, fn w -> [~e[<div class="col">], w, ~e[</div>]] end),
208
~e[</div>]
209
]
210
end
211
73
212
@doc """
74
- Widget to select ...
213
Creates a fragment that can be reused in the same template.
214
215
It's meant to be used in an EEx template, which has some synctatic
216
restrictions that make it hard to set a variable to a an EEx fragment.
217
218
## Example
219
220
<%= fragment widget do %>
221
<div class="my-widget">
222
Add an EEx fragment here.
223
Can contain <%= @dynamic %> fragments.
224
</div>
225
<% end %>
226
227
<%= widget %>
228
"""
229
defmacro fragment(var, [do: body]) do
230
quote do
231
unquote(var) = unquote(body)
232
end
233
end
234
235
defp classes_for_input(form, field, user_specified_classes) do
236
case form.errors do
237
[] ->
238
user_specified_classes
239
240
_other ->
241
case Keyword.fetch(form.errors, field) do
242
# The field contains an error
243
{:ok, _error} ->
244
[user_specified_classes, " is-invalid"]
245
246
# The field doesn't contain an error
247
:error ->
248
[user_specified_classes, " is-valid"]
249
end
250
end
251
end
252
253
defp forage_generic_input(form, field, input_fun, opts, input_class) do
254
{class, opts} = Keyword.pop(opts, :class, input_class)
255
classes = classes_for_input(form, field, class)
256
input_fun.(form, field, [{:class, classes} | opts])
257
end
258
259
phoenix_form_input_names = [
260
:checkbox,
261
:color_input,
262
:date_input,
263
:date_select,
264
:datetime_local_input,
265
:datetime_select,
266
:email_input,
267
:file_input,
268
:input_type,
269
:number_input,
270
:password_input,
271
:radio_button,
272
:range_input,
273
:search_input,
274
:telephone_input,
275
:text_input,
276
:textarea,
277
:time_input,
278
:time_select,
279
:url_input
280
]
281
282
input_class_for = fn
283
input when input in [:radio_button, :checkbox] -> "form-check-input"
284
_other -> "form-control"
285
end
286
287
for name <- phoenix_form_input_names do
288
forage_function_name = :"forage_#{name}"
289
input_class = input_class_for.(name)
290
291
@doc """
292
See docs for `Phoenix.HTML.Form.#{name}/3`.
293
"""
294
def unquote(forage_function_name)(form, field, opts \\ []) do
295
forage_generic_input(form, field, &Form.unquote(name)/3, opts, unquote(input_class))
296
end
297
end
298
299
300
@doc """
301
Widget to select multiple external resources using the Javascript Select2 widget.
302
303
Parameters:
304
* `form` (`%Phoenix.HTml.Form.t/1`)- the form
305
* `displayer` (module) - a module with a `displayer.as_text/1` function to display the foreign resource.
306
* `field` (atom)
75
307
76
308
Required options:
77
309
78
310
* `:path` (required) - the URL from which to request the data
79
311
This function won't be applied to values requested from the server after
80
312
the initial render.
81
- * `:remote_field` (required) - The remote field on the other side of the association.
82
- * `:foreign_key` (optionsl) - The name of the foreign key (as a string or an atom).
313
* `:foreign_key` (optional) - The name of the foreign key (as a string or an atom).
83
314
If this is not supplied it will default to `field_id`
84
315
"""
85
316
def forage_select(form, field, opts) do
86
317
# Params
87
318
path = Keyword.fetch!(opts, :path)
88
- remote_field = Keyword.fetch!(opts, :remote_field)
89
319
foreign_key = Keyword.get(opts, :foreign_key, "#{field}_id")
320
class = Keyword.get(opts, :class, "form-control")
90
321
# Derived values
91
322
field_value = Map.get(form.data, field)
92
323
field_id = field_value && Map.get(field_value, :id, nil)
93
324
field_text = display_relation(field_value)
94
325
95
- IO.inspect(form.data, label: "form.data")
96
- IO.inspect(field_value, label: "field_value")
97
-
98
326
~e"""
99
327
<select
100
328
name="<%= form.name %>[<%= foreign_key %>]"
101
- class="form-control"
329
class="<%= class %>"
330
data-value="<%= field_id %>"
102
331
data-forage-select2-widget="true"
103
- data-url="<%= path %>"
104
- data-field="<%= remote_field %>">
332
data-url="<%= path %>">
105
333
<option value="<%= field_id %>"><%= field_text %></option>
106
334
</select>
107
335
"""
108
336
end
109
337
338
def forage_static_select(form, field, opts) do
339
field_name_in_input =
340
# There are three cases:
341
case Keyword.get(opts, :foreign_key) do
342
# The foreign key isn't given.
343
# We assume this is a one-to-* relation and infer
344
# the foreign key name accordingly
345
nil -> "#{field}_id"
346
# The user has specified that this field is not
347
# a foreign relation and we don't have a foreign key.
348
# In this case, we use the field name.
349
false -> to_string(field)
350
# The user has given an explicit foreign key.
351
# We respect that choice.
352
other -> to_string(other)
353
end
354
355
class = Keyword.get(opts, :class, "form-control")
356
options = Keyword.fetch!(opts, :options)
357
field_value = Map.get(form.data, field)
358
field_id = get_field_id(field_value, :id)
359
360
~e"""
361
<select name="<%= form.name %>[<%= field_name_in_input %>]" class="<%= class %>">
362
<option></option>
363
<%= for option <- options do %>
364
<%= if Map.get(option, :id) == field_id do %>
365
<option selected="selected" value="<%= options.id %>"><%= display_relation(option) %></option>
366
<% else %>
367
<option value="<%= option.id %>"><%= display_relation(option) %></option>
368
<% end %>
369
<% end %>
370
</select>
371
"""
372
end
373
374
defp get_field_id(field_value, id_field) do
375
case field_value do
376
nil -> nil
377
%NotLoaded{} -> nil
378
value -> value && Map.get(value, id_field, nil)
379
end
380
end
381
110
382
defp display_relation(nil), do: ""
111
383
defp display_relation(%NotLoaded{} = _field), do: ""
112
- defp display_relation(%{__struct__: _} = field), do: Display.display(field)
384
defp display_relation(%{__struct__: _} = field), do: ForageWeb.Display.as_text(field)
113
385
114
386
@doc """
115
- Displays a struct
116
- """
117
- def forage_display(nil), do: ""
118
- def forage_display(%NotLoaded{} = _field), do: ""
119
- def forage_display(%{__struct__: _} = field), do: Display.display(field)
387
Widget to select multiple external resources using the Javascript Select2 widget.
120
388
121
- # @doc """
122
- # Widget to select ...
123
-
124
- # Required options:
125
-
126
- # * `:path` (required) - the URL from which to request the data
127
- # This function won't be applied to values requested from the server after
128
- # the initial render.
129
- # * `:remote_field` (required) - The remote field on the other side of the association.
130
- # * `:foreign_key` (optionsl) - The name of the foreign key (as a string or an atom).
131
- # If this is not supplied it will default to `field_id`
132
- # """
133
- # def forage_select_many(form, field, opts) do
134
- # # Params
135
- # path = Keyword.fetch!(opts, :path)
136
- # display = Keyword.fetch!(opts, :display)
137
- # remote_field = Keyword.fetch!(opts, :remote_field)
138
- # foreign_key = Keyword.get(opts, :foreign_key, "#{field}_id")
139
- # # Derived values
140
- # field_value = Map.get(form.data, field)
141
-
142
- # ~e"""
143
- # <select
144
- # multiple="true"
145
- # name="__select_many__<%= form.name %>[<%= foreign_key %>]"
146
- # class="form-control"
147
- # data-forage-select2-widget="true"
148
- # data-url="<%= path %>"
149
- # data-field="<%= remote_field %>">
150
- # <option value="<%= field_value && field_value.id %>"><%= display.(field_value) %></option>
151
- # </select>
152
- # """
153
- # end
154
-
155
- @doc """
156
- Widget to select ...
389
Parameters:
390
* `form` (`%Phoenix.HTml.Form.t/1`)- the form
391
* `field` (atom)
157
392
158
393
Required options:
159
394
160
395
* `:path` (required) - the URL from which to request the data
161
396
This function won't be applied to values requested from the server after
162
- the initial render.
163
- * `:remote_field` (required) - The remote field on the other side of the association.
397
* `:foreign_key` (optional) - The name of the foreign key (as a string or an atom).
398
If this is not supplied it will default to `"\#\{field\}_id"`
399
"""
400
def forage_multiple_select(form, field, opts) do
401
# Params
402
path = Keyword.fetch!(opts, :path)
403
# Derived values
404
field_values =
405
case Map.get(form.data, field) do
406
%NotLoaded{} -> []
407
other when is_list(other) -> other
408
end
409
410
results =
411
for entry <- field_values do
412
entry.id
413
end
414
415
# Try not to depend on Jason.encode!()
416
rendered_initial_values = inspect(results)
417
418
~e"""
419
<select
420
multiple="true"
421
name="<%= form.name %>[__forage_select_many__<%= to_string(field) %>][]"
422
class="form-control"
423
data-values="<%= rendered_initial_values %>"
424
data-forage-select2-widget="true"
425
data-url="<%= path %>">
426
<%= for field_value <- field_values do %>
427
<option selected="selected" value="<%= field_value && field_value.id %>"><%= display_relation(field_value) %></option>
428
<% end %>
429
</select>
430
"""
431
end
432
433
@doc """
434
Widget to select an external resource using the Javascript Select2 widget.
435
436
Parameters:
437
* `form` (`%Phoenix.HTml.Form.t/1`)- the form
438
* `field` (atom)
439
440
Required options:
441
442
* `:path` (required) - the URL from which to request the data
164
443
* `:foreign_key` (optionsl) - The name of the foreign key (as a string or an atom).
165
444
If this is not supplied it will default to `field_id`
166
445
"""
167
446
def forage_select_filter(form, field, opts) do
168
447
# Params
169
448
path = Keyword.fetch!(opts, :path)
170
- remote_field = Keyword.fetch!(opts, :remote_field)
171
449
field_value = Map.get(form.data, field)
172
450
field_id = field_value && Map.get(field_value, :id, nil)
173
451
field_text = display_relation(field_value)
174
452
175
453
~e"""
176
454
<select
177
- name="_search[<%= field %>_id][val]"
455
name="_filter[<%= field %>_id][val]"
178
456
class="form-control"
179
457
data-forage-select2-widget="true"
180
- data-url="<%= path %>"
181
- data-field="<%= remote_field %>">
458
data-url="<%= path %>">
182
459
<option value="<%= field_id %>"><%= field_text %></option>
183
460
</select>
184
- <input type="hidden" name="_search[<%= field %>_id][op]" value="equal_to"/>
461
<input type="hidden" name="_filter[<%= field %>_id][op]" value="equal_to"/>
185
462
"""
186
463
end
187
464
 
@@ -204,8 481,8 @@ defmodule ForageWeb.ForageView do
204
481
@operator_class "col-sm-3"
205
482
@value_class "col-sm-9"
206
483
207
- defp name_to_search_id(name) do
208
- ["_search", to_string(name)]
484
defp name_to_filter_id(name) do
485
["_filter", to_string(name)]
209
486
end
210
487
211
488
defp sort_direction(conn, field) do
 
@@ -249,28 526,28 @@ defmodule ForageWeb.ForageView do
249
526
end
250
527
251
528
@doc """
252
- A link to the previous page of search results.
529
A link to the previous page of filter results.
253
530
Returns the empty string if the previous page doesn't exist.
254
531
"""
255
532
def forage_pagination_link_previous(conn, resource, mod, fun, contents) do
256
533
if resource.metadata.before do
257
534
before_params = Map.put(conn.params, :_pagination, %{before: resource.metadata.before})
258
535
destination = apply(mod, fun, [conn, :index, before_params])
259
- ~e'<li><a href="<%= destination %>"><%= contents %></a></li>'
536
~e'<li class="page-item"><a class="page-link" href="<%= destination %>"><%= contents %></a></li>'
260
537
else
261
538
~e''
262
539
end
263
540
end
264
541
265
542
@doc """
266
- A link to the next page of search results.
543
A link to the next page of filter results.
267
544
Returns the empty string if the next page doesn't exist.
268
545
"""
269
546
def forage_pagination_link_next(conn, resource, mod, fun, contents) do
270
547
if resource.metadata.after do
271
548
after_params = Map.put(conn.params, :_pagination, %{after: resource.metadata.after})
272
549
destination = apply(mod, fun, [conn, :index, after_params])
273
- ~e'<li><a href="<%= destination %>"><%= contents %></a></li>'
550
~e'<li class="page-item"><a class="page-link" href="<%= destination %>"><%= contents %></a></li>'
274
551
else
275
552
~e''
276
553
end
 
@@ -278,15 555,17 @@ defmodule ForageWeb.ForageView do
278
555
279
556
@doc """
280
557
An already styled "pagination widget" containing a link to the next page
281
- and to the previous page of search results.
558
and to the previous page of filter results.
282
559
283
560
If either the previous page or the next page doesn't exist,
284
561
the respective link will be empty.
562
563
TODO
285
564
"""
286
565
def forage_pagination_widget(conn, resource, mod, fun, options) do
287
566
previous_text = Keyword.get(options, :previous, "« Previous")
288
567
next_text = Keyword.get(options, :next, "Next »")
289
- classes = Keyword.get(options, :classes, "pagination-sm no-margin pull-right")
568
classes = Keyword.get(options, :classes, "justify-content-center")
290
569
291
570
~e"""
292
571
<ul class="pagination <%= classes %>">
 
@@ -301,12 580,14 @@ defmodule ForageWeb.ForageView do
301
580
"""
302
581
def forage_horizontal_form_group(name, opts \\ [], do: content) do
303
582
label = Keyword.get(opts, :label, [Naming.humanize(name), ":"])
304
- input_id = Keyword.get(opts, :id, name_to_search_id(name))
583
input_id = Keyword.get(opts, :id, name_to_filter_id(name))
305
584
{label_class, inputs_class} = Keyword.get(opts, :classes, {"col-sm-2", "col-sm-10"})
306
585
307
586
~e"""
308
- <div class="form-group">
309
- <label for="<%= input_id %>" class="control-label <%= label_class %>"><%= label %></label>
587
<div class="form-group row">
588
<label for="<%= input_id %>" class="text-right col-form-label <%= label_class %>">
589
<%= label %>
590
</label>
310
591
<div class="<%= inputs_class %>">
311
592
<%= content %>
312
593
</div>
 
@@ -314,20 595,20 @@ defmodule ForageWeb.ForageView do
314
595
"""
315
596
end
316
597
317
- def forage_active_filters?(%{params: %{"_search" => _}} = _conn), do: true
598
def forage_active_filters?(%{params: %{"_filter" => _}} = _conn), do: true
318
599
319
600
def forage_active_filters?(_conn), do: false
320
601
321
- @spec forage_search_form_for(
602
@spec forage_filter_form_for(
322
603
FormData.t(),
323
604
String.t(),
324
605
Keyword.t(),
325
606
(FormData.t() -> Phoenix.HTML.unsafe())
326
607
) :: Phoenix.HTML.safe()
327
- def forage_search_form_for(conn, action, options \\ [], fun) do
608
def forage_filter_form_for(conn, action, options \\ [], fun) do
328
609
new_options =
329
610
options
330
- |> Keyword.put_new(:as, :_search)
611
|> Keyword.put_new(:as, :_filter)
331
612
|> Keyword.put_new(:method, "get")
332
613
|> Keyword.put_new(:class, "form-horizontal")
333
614
 
@@ -371,19 652,27 @@ defmodule ForageWeb.ForageView do
371
652
~e"""
372
653
<div class="row">
373
654
<div class="<%= operator_class %>">
374
- <select name="_search[<%= name %>][op]" class="form-control">
655
<select name="_filter[<%= name %>][op]" class="form-control">
375
656
<%= for {op_name, op_value} <- operators do %>
376
657
<option value="<%= op_value %>"<%= if op_value == operator do %> selected="true"<% end %>><%= op_name %></option>
377
658
<% end %>
378
659
</select>
379
660
</div>
380
661
<div class="<%= value_class %>">
381
- <input type="<%= type %>" name="_search[<%= name %>][val]" class="form-control" value="<%= value %>"></input>
662
<input type="<%= type %>" name="_filter[<%= name %>][val]" class="form-control" value="<%= value %>"></input>
382
663
</div>
383
664
</div>
384
665
"""
385
666
end
386
667
668
def forage_as_html(resource) do
669
Display.as_html(resource)
670
end
671
672
def forage_as_text(resource) do
673
Display.as_text(resource)
674
end
675
387
676
@doc """
388
677
A filter that works on text.
389
678
 
@@ -455,7 744,7 @@ defmodule ForageWeb.ForageView do
455
744
456
745
opts =
457
746
opts
458
- |> Keyword.put_new(:name, "_search[#{name}][val]")
747
|> Keyword.put_new(:name, "_filter[#{name}][val]")
459
748
|> Keyword.put_new(:value, value)
460
749
461
750
input = forage_date_input(form, name, opts)
 
@@ -463,7 752,7 @@ defmodule ForageWeb.ForageView do
463
752
~e"""
464
753
<div class="row">
465
754
<div class="<%= operator_class %>">
466
- <select name="_search[<%= name %>][op]" class="form-control">
755
<select name="_filter[<%= name %>][op]" class="form-control">
467
756
<%= for {op_name, op_value} <- operators do %>
468
757
<option value="<%= op_value %>"<%= if op_value == operator do %> selected="true"<% end %>><%= op_name %></option>
469
758
<% end %>
 
@@ -496,42 785,53 @@ defmodule ForageWeb.ForageView do
496
785
end
497
786
498
787
@doc """
499
- Datepicker widget based on bootstrap calendar (heavy but gets the work done)
788
A filter that works on datetime objects.
789
790
It supports the following operators:
791
792
* Equal to
793
* Greater than
794
* Less than
795
* Greater than or equal to
796
* Less than or equal to
797
798
## Examples
799
800
TODO
500
801
"""
501
- def forage_date_input(form, field, opts \\ []) do
502
- opts =
503
- opts
504
- |> Keyword.put_new(:"data-forage-datepicker-widget", "true")
505
- |> Keyword.put_new(:class, "form-control")
506
-
507
- icon = Keyword.get(opts, :icon, "fa fa-calendar")
508
- input = generic_input(:text, form, field, opts)
509
-
510
- ~e"""
511
- <div class="input-group">
512
- <%= input %>
513
- <%= if icon do %>
514
- <span class="input-group-addon"><i class="<%= icon %>"></i></span>
515
- <% end %>
516
- </div>
517
- """
802
def forage_datetime_filter(form, name, opts \\ []) do
803
generic_forage_filter("datetime", form, name, @number_operators, opts)
518
804
end
519
805
520
- # Copied from Phoenix.Form
521
- defp generic_input(type, form, field, opts)
522
- when is_list(opts) and (is_atom(field) or is_binary(field)) do
523
- opts =
524
- opts
525
- |> Keyword.put_new(:type, type)
526
- |> Keyword.put_new(:id, Form.input_id(form, field))
527
- |> Keyword.put_new(:name, Form.input_name(form, field))
528
- |> Keyword.put_new(:value, Form.input_value(form, field))
529
- |> Keyword.update!(:value, &maybe_html_escape/1)
806
# @doc """
807
# Datepicker widget based on bootstrap calendar (heavy but gets the work done)
808
# Actually it might be better to just use the default HTMl datepicker widget.
809
# """
810
# def forage_date_input(form, field, opts \\ []) do
811
# opts =
812
# opts
813
# |> Keyword.delete(:datepicker_opts)
814
# |> Keyword.put_new(:"data-forage-datepicker-widget", "true")
815
# |> Keyword.put_new(:class, "form-control")
530
816
531
- Tag.tag(:input, opts)
532
- end
817
# generic_input(:date, form, field, opts)
818
# end
819
820
# # Copied from Phoenix.Form
821
# defp generic_input(type, form, field, opts)
822
# when is_list(opts) and (is_atom(field) or is_binary(field)) do
823
# opts =
824
# opts
825
# |> Keyword.put_new(:type, type)
826
# |> Keyword.put_new(:id, Form.input_id(form, field))
827
# |> Keyword.put_new(:name, Form.input_name(form, field))
828
# |> Keyword.put_new(:value, Form.input_value(form, field))
829
# |> Keyword.update!(:value, &maybe_html_escape/1)
830
831
# Tag.tag(:input, opts)
832
# end
533
833
534
834
# Copied from Phoenix.Form
535
- defp maybe_html_escape(nil), do: nil
536
- defp maybe_html_escape(value), do: html_escape(value)
835
# defp maybe_html_escape(nil), do: nil
836
# defp maybe_html_escape(value), do: html_escape(value)
537
837
end
changed lib/forage_web/naming.ex
 
@@ -1,135 1,135 @@
1
- defmodule ForageWeb.Naming do
2
- @moduledoc false
3
-
4
- # This is a copy of `Phoenix.Naming` from Phoenix 1.4.0
5
- # The source was copied here so that we mantain backward compatibility between Forage versions,
6
- # independently of what Phoenix does.
7
-
8
- @doc """
9
- Extracts the resource name from an alias.
10
-
11
- ## Examples
12
-
13
- iex> ForageWeb.Naming.resource_name(MyApp.User)
14
- "user"
15
-
16
- iex> ForageWeb.Naming.resource_name(MyApp.UserView, "View")
17
- "user"
18
-
19
- """
20
- @spec resource_name(String.Chars.t(), String.t()) :: String.t()
21
- def resource_name(alias, suffix \\ "") do
22
- alias
23
- |> to_string()
24
- |> Module.split()
25
- |> List.last()
26
- |> unsuffix(suffix)
27
- |> underscore()
28
- end
29
-
30
- @doc """
31
- Removes the given suffix from the name if it exists.
32
-
33
- ## Examples
34
-
35
- iex> ForageWeb.Naming.unsuffix("MyApp.User", "View")
36
- "MyApp.User"
37
-
38
- iex> ForageWeb.Naming.unsuffix("MyApp.UserView", "View")
39
- "MyApp.User"
40
-
41
- """
42
- @spec unsuffix(String.t(), String.t()) :: String.t()
43
- def unsuffix(value, suffix) do
44
- string = to_string(value)
45
- suffix_size = byte_size(suffix)
46
- prefix_size = byte_size(string) - suffix_size
47
-
48
- case string do
49
- <<prefix::binary-size(prefix_size), ^suffix::binary>> -> prefix
50
- _ -> string
51
- end
52
- end
53
-
54
- @doc """
55
- Converts String to underscore case.
56
-
57
- ## Examples
58
-
59
- iex> ForageWeb.Naming.underscore("MyApp")
60
- "my_app"
61
-
62
- In general, `underscore` can be thought of as the reverse of
63
- `camelize`, however, in some cases formatting may be lost:
64
-
65
- ForageWeb.Naming.underscore "SAPExample" #=> "sap_example"
66
- ForageWeb.Naming.camelize "sap_example" #=> "SapExample"
67
-
68
- """
69
- @spec underscore(String.t()) :: String.t()
70
-
71
- def underscore(value), do: Macro.underscore(value)
72
-
73
- defp to_lower_char(char) when char in ?A..?Z, do: char 32
74
- defp to_lower_char(char), do: char
75
-
76
- @doc """
77
- Converts String to camel case.
78
-
79
- Takes an optional `:lower` option to return lowerCamelCase.
80
-
81
- ## Examples
82
-
83
- iex> ForageWeb.Naming.camelize("my_app")
84
- "MyApp"
85
-
86
- iex> ForageWeb.Naming.camelize("my_app", :lower)
87
- "myApp"
88
-
89
- In general, `camelize` can be thought of as the reverse of
90
- `underscore`, however, in some cases formatting may be lost:
91
-
92
- ForageWeb.Naming.underscore "SAPExample" #=> "sap_example"
93
- ForageWeb.Naming.camelize "sap_example" #=> "SapExample"
94
-
95
- """
96
- @spec camelize(String.t()) :: String.t()
97
- def camelize(value), do: Macro.camelize(value)
98
-
99
- @spec camelize(String.t(), :lower) :: String.t()
100
- def camelize("", :lower), do: ""
101
-
102
- def camelize(<<?_, t::binary>>, :lower) do
103
- camelize(t, :lower)
104
- end
105
-
106
- def camelize(<<h, _t::binary>> = value, :lower) do
107
- <<_first, rest::binary>> = camelize(value)
108
- <<to_lower_char(h)>> <> rest
109
- end
110
-
111
- @doc """
112
- Converts an attribute/form field into its humanize version.
113
-
114
- iex> ForageWeb.Naming.humanize(:username)
115
- "Username"
116
- iex> ForageWeb.Naming.humanize(:created_at)
117
- "Created at"
118
- iex> ForageWeb.Naming.humanize("user_id")
119
- "User"
120
- """
121
- @spec humanize(atom | String.t()) :: String.t()
122
- def humanize(atom) when is_atom(atom),
123
- do: humanize(Atom.to_string(atom))
124
-
125
- def humanize(bin) when is_binary(bin) do
126
- bin =
127
- if String.ends_with?(bin, "_id") do
128
- binary_part(bin, 0, byte_size(bin) - 3)
129
- else
130
- bin
131
- end
132
-
133
- bin |> String.replace("_", " ") |> String.capitalize()
134
- end
135
- end
1
defmodule ForageWeb.Naming do
2
@moduledoc false
3
4
# This is a copy of `Phoenix.Naming` from Phoenix 1.4.0
5
# The source was copied here so that we mantain backward compatibility between Forage versions,
6
# independently of what Phoenix does.
7
8
@doc """
9
Extracts the resource name from an alias.
10
11
## Examples
12
13
iex> ForageWeb.Naming.resource_name(MyApp.User)
14
"user"
15
16
iex> ForageWeb.Naming.resource_name(MyApp.UserView, "View")
17
"user"
18
19
"""
20
@spec resource_name(String.Chars.t(), String.t()) :: String.t()
21
def resource_name(alias, suffix \\ "") do
22
alias
23
|> to_string()
24
|> Module.split()
25
|> List.last()
26
|> unsuffix(suffix)
27
|> underscore()
28
end
29
30
@doc """
31
Removes the given suffix from the name if it exists.
32
33
## Examples
34
35
iex> ForageWeb.Naming.unsuffix("MyApp.User", "View")
36
"MyApp.User"
37
38
iex> ForageWeb.Naming.unsuffix("MyApp.UserView", "View")
39
"MyApp.User"
40
41
"""
42
@spec unsuffix(String.t(), String.t()) :: String.t()
43
def unsuffix(value, suffix) do
44
string = to_string(value)
45
suffix_size = byte_size(suffix)
46
prefix_size = byte_size(string) - suffix_size
47
48
case string do
49
<<prefix::binary-size(prefix_size), ^suffix::binary>> -> prefix
50
_ -> string
51
end
52
end
53
54
@doc """
55
Converts String to underscore case.
56
57
## Examples
58
59
iex> ForageWeb.Naming.underscore("MyApp")
60
"my_app"
61
62
In general, `underscore` can be thought of as the reverse of
63
`camelize`, however, in some cases formatting may be lost:
64
65
ForageWeb.Naming.underscore "SAPExample" #=> "sap_example"
66
ForageWeb.Naming.camelize "sap_example" #=> "SapExample"
67
68
"""
69
@spec underscore(String.t()) :: String.t()
70
71
def underscore(value), do: Macro.underscore(value)
72
73
defp to_lower_char(char) when char in ?A..?Z, do: char 32
74
defp to_lower_char(char), do: char
75
76
@doc """
77
Converts String to camel case.
78
79
Takes an optional `:lower` option to return lowerCamelCase.
80
81
## Examples
82
83
iex> ForageWeb.Naming.camelize("my_app")
84
"MyApp"
85
86
iex> ForageWeb.Naming.camelize("my_app", :lower)
87
"myApp"
88
89
In general, `camelize` can be thought of as the reverse of
90
`underscore`, however, in some cases formatting may be lost:
91
92
ForageWeb.Naming.underscore "SAPExample" #=> "sap_example"
93
ForageWeb.Naming.camelize "sap_example" #=> "SapExample"
94
95
"""
96
@spec camelize(String.t()) :: String.t()
97
def camelize(value), do: Macro.camelize(value)
98
99
@spec camelize(String.t(), :lower) :: String.t()
100
def camelize("", :lower), do: ""
101
102
def camelize(<<?_, t::binary>>, :lower) do
103
camelize(t, :lower)
104
end
105
106
def camelize(<<h, _t::binary>> = value, :lower) do
107
<<_first, rest::binary>> = camelize(value)
108
<<to_lower_char(h)>> <> rest
109
end
110
111
@doc """
112
Converts an attribute/form field into its humanize version.
113
114
iex> ForageWeb.Naming.humanize(:username)
115
"Username"
116
iex> ForageWeb.Naming.humanize(:created_at)
117
"Created at"
118
iex> ForageWeb.Naming.humanize("user_id")
119
"User"
120
"""
121
@spec humanize(atom | String.t()) :: String.t()
122
def humanize(atom) when is_atom(atom),
123
do: humanize(Atom.to_string(atom))
124
125
def humanize(bin) when is_binary(bin) do
126
bin =
127
if String.ends_with?(bin, "_id") do
128
binary_part(bin, 0, byte_size(bin) - 3)
129
else
130
bin
131
end
132
133
bin |> String.replace("_", " ") |> String.capitalize()
134
end
135
end
changed mix.exs
 
@@ -1,7 1,7 @@
1
1
defmodule Forage.MixProject do
2
2
use Mix.Project
3
3
4
- @version "0.3.0"
4
@version "0.4.0"
5
5
6
6
def project do
7
7
[
 
@@ -11,7 11,8 @@ defmodule Forage.MixProject do
11
11
start_permanent: Mix.env() == :prod,
12
12
description: description(),
13
13
package: package(),
14
- deps: deps()
14
deps: deps(),
15
aliases: aliases()
15
16
]
16
17
end
17
18
 
@@ -27,8 28,9 @@ defmodule Forage.MixProject do
27
28
[
28
29
{:ecto, "~> 3.0"},
29
30
{:phoenix_html, "~> 2.10"},
30
- {:paginator, "~> 0.6.0"},
31
- {:ex_doc, "~> 0.19", only: :dev}
31
{:json, ">= 0.0.0"},
32
{:paginator, "~> 1.0"},
33
{:ex_doc, "~> 0.23", only: :dev}
32
34
]
33
35
end
34
36
 
@@ -44,4 46,10 @@ defmodule Forage.MixProject do
44
46
links: %{"GitHub" => "https://github.com/tmbb/forage"}
45
47
]
46
48
end
49
50
defp aliases() do
51
[
52
publish: "run scripts/publish.exs"
53
]
54
end
47
55
end