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
|