diff --git a/lib/ecto/query/api.ex b/lib/ecto/query/api.ex index f161889a84..052dd3994c 100644 --- a/lib/ecto/query/api.ex +++ b/lib/ecto/query/api.ex @@ -828,6 +828,25 @@ defmodule Ecto.Query.API do """ def map(source, fields), do: doc!([source, fields]) + @doc """ + Used in `select` to return a source as a map. + + Ordinarly, selecting a source will return the result as a struct: + + # Returns [%Post{}, ...] + Repo.all(from p in Post, select: p) + + There may be situations where you would like to return the result as a map instead. + For example, if you would like to immediately encode your results to JSON without + needing to handle the Ecto specific metadata fields. + + To this end, you can use `map/1` in the following way: + + # Returns [%{...}, ...] with all of the query fields in the schema + Repo.all(from p in Post, select: map(p)) + """ + def map(source), do: doc!([source]) + @doc """ Merges the map on the right over the map on the left. diff --git a/lib/ecto/query/builder.ex b/lib/ecto/query/builder.ex index f7a946308c..dc1d94e03a 100644 --- a/lib/ecto/query/builder.ex +++ b/lib/ecto/query/builder.ex @@ -970,9 +970,9 @@ defmodule Ecto.Query.Builder do A escaped variable is represented internally as `&0`, `&1` and so on. """ - @spec escape_var!(atom, Keyword.t()) :: Macro.t() - def escape_var!(var, vars) do - {:{}, [], [:&, [], [find_var!(var, vars)]]} + @spec escape_var!(atom, Keyword.t(), Keyword.t()) :: Macro.t() + def escape_var!(var, vars, meta \\ []) do + {:{}, [], [:&, meta, [find_var!(var, vars)]]} end @doc """ diff --git a/lib/ecto/query/builder/select.ex b/lib/ecto/query/builder/select.ex index 01c34ad33f..0d1955dfc4 100644 --- a/lib/ecto/query/builder/select.ex +++ b/lib/ecto/query/builder/select.ex @@ -102,6 +102,13 @@ defmodule Ecto.Query.Builder.Select do Enum.map_reduce(list, params_acc, &escape(&1, &2, vars, env)) end + # map(var) + defp escape({:map, _, [{var, _, context}]}, params_acc, vars, _env) + when is_atom(var) and is_atom(context) do + expr = Builder.escape_var!(var, vars, [map: true]) + {expr, params_acc} + end + # map/struct(var, [:foo, :bar]) defp escape({tag, _, [{var, _, context}, fields]}, {params, acc}, vars, env) when tag in [:map, :struct] and is_atom(var) and is_atom(context) do diff --git a/lib/ecto/query/planner.ex b/lib/ecto/query/planner.ex index 7a831a0247..044c2d73fa 100644 --- a/lib/ecto/query/planner.ex +++ b/lib/ecto/query/planner.ex @@ -498,10 +498,10 @@ defmodule Ecto.Query.Planner do {name, fields} end - defp subquery_select({:%{}, _, [{:|, _, [{:&, [], [ix]}, pairs]}]} = expr, take, query) do + defp subquery_select({:%{}, _, [{:|, _, [{:&, meta, [ix]}, pairs]}]} = expr, take, query) do assert_subquery_fields!(query, expr, pairs) drop = Map.new(pairs, fn {key, _} -> {key, nil} end) - {source, _} = source_take!(:select, query, take, ix, ix, drop) + {source, _} = source_take!(:select, query, take, ix, ix, drop, meta[:map]) # In case of map updates, we need to remove duplicated fields # at query time because we use the field names as aliases and @@ -515,8 +515,8 @@ defmodule Ecto.Query.Planner do {nil, pairs} end - defp subquery_select({:&, _, [ix]}, take, query) do - {source, _} = source_take!(:select, query, take, ix, ix, %{}) + defp subquery_select({:&, meta, [ix]}, take, query) do + {source, _} = source_take!(:select, query, take, ix, ix, %{}, meta[:map]) fields = subquery_source_fields(source) {keep_source_or_struct(source), subquery_fields(fields, ix)} end @@ -1886,13 +1886,13 @@ defmodule Ecto.Query.Planner do end end - defp collect_fields({:&, _, [0]}, fields, :none, query, take, _keep_literals?, drop) do - {expr, taken} = source_take!(:select, query, take, 0, 0, drop) + defp collect_fields({:&, meta, [0]}, fields, :none, query, take, _keep_literals?, drop) do + {expr, taken} = source_take!(:select, query, take, 0, 0, drop, meta[:map]) {{:source, :from}, fields, {{:source, :from}, expr, taken, drop}} end defp collect_fields( - {:&, _, [0]}, + {:&, meta, [0]}, fields, {from_expr, _, _, cached_drop} = from, query, @@ -1908,15 +1908,15 @@ defmodule Ecto.Query.Planner do if drop == cached_drop do from else - {from_source, from_fields} = source_take!(:select, query, take, 0, 0, drop) + {from_source, from_fields} = source_take!(:select, query, take, 0, 0, drop, meta[:map]) {from_expr, from_source, from_fields, drop} end {{:source, :from}, fields, from} end - defp collect_fields({:&, _, [ix]}, fields, from, query, take, _keep_literals?, drop) do - {expr, taken} = source_take!(:select, query, take, ix, ix, drop) + defp collect_fields({:&, meta, [ix]}, fields, from, query, take, _keep_literals?, drop) do + {expr, taken} = source_take!(:select, query, take, ix, ix, drop, meta[:map]) {expr, Enum.reverse(taken, fields), from} end @@ -2229,7 +2229,7 @@ defmodule Ecto.Query.Planner do defp collect_assocs(exprs, fields, query, tag, take, [{assoc, {ix, children}} | tail]) do to_take = get_preload_source!(query, ix) {fetch, take_children} = fetch_assoc(tag, take, assoc) - {expr, taken} = take!(to_take, query, fetch, assoc, ix, %{}) + {expr, taken} = take!(to_take, query, fetch, assoc, ix, %{}, false) exprs = [expr | exprs] fields = Enum.reverse(taken, fields) {exprs, fields} = collect_assocs(exprs, fields, query, tag, take_children, children) @@ -2248,12 +2248,12 @@ defmodule Ecto.Query.Planner do end end - defp source_take!(kind, query, take, field, ix, drop) do + defp source_take!(kind, query, take, field, ix, drop, map_source?) do source = get_source!(kind, query, ix) - take!(source, query, Access.fetch(take, field), field, ix, drop) + take!(source, query, Access.fetch(take, field), field, ix, drop, map_source?) end - defp take!(source, query, fetched, field, ix, drop) do + defp take!(source, query, fetched, field, ix, drop, map_source?) do case {fetched, source} do {{:ok, {:struct, _}}, {:fragment, _, _}} -> error!(query, "it is not possible to return a struct subset of a fragment") @@ -2310,6 +2310,7 @@ defmodule Ecto.Query.Planner do {types, fields} = select_dump(schema.__schema__(:query_fields), schema.__schema__(:dump), ix, drop) + schema = if map_source?, do: nil, else: schema {{:source, {source, schema}, prefix || query.prefix, types}, fields} {:error, %Ecto.SubQuery{select: select}} -> diff --git a/test/ecto/query/planner_test.exs b/test/ecto/query/planner_test.exs index 9689da3983..27df2e4311 100644 --- a/test/ecto/query/planner_test.exs +++ b/test/ecto/query/planner_test.exs @@ -2566,11 +2566,69 @@ defmodule Ecto.Query.PlannerTest do end test "normalize: select source on fragment with columns" do - query = from f in fragment("select 1", columns: [:x]) + query = from(f in fragment("select 1", columns: [:x])) {_, _, _, select} = normalize_with_params(query) assert %{from: {_, {:map, [x: {:value, :any}]}}} = select end + test "normalize: select with map/1" do + {query, _, _, %{from: from}} = Post |> select([p], map(p)) |> normalize_with_params() + assert query.select.expr == {:&, [map: true], [0]} + + assert query.select.fields == + select_fields( + [ + :id, + :post_title, + :text, + :code, + :posted, + :visits, + :links, + :preferences, + :status, + :parameterized_map, + :meta, + :metas + ], + 0 + ) + + assert {_, {:source, {"posts", nil}, _, _}} = from + end + + test "normalize: select_merge with map/1" do + {query, _, _, %{from: from}} = + Post + |> select([p], map(p)) + |> select_merge([p], map(p, [:payload])) + |> normalize_with_params() + + assert query.select.expr == {:merge, [], [{:&, [map: true], [0]}, {:&, [], [0]}]} + + assert query.select.fields == + select_fields( + [ + :payload, + :id, + :post_title, + :text, + :code, + :posted, + :visits, + :links, + :preferences, + :status, + :parameterized_map, + :meta, + :metas + ], + 0 + ) + + assert {_, {:source, {"posts", nil}, _, _}} = from + end + test "normalize: select with map/2" do query = Post |> select([p], map(p, [:id, :title])) |> normalize() assert query.select.expr == {:&, [], [0]} diff --git a/test/ecto/query/subquery_test.exs b/test/ecto/query/subquery_test.exs index 1718f29232..acd53ab00c 100644 --- a/test/ecto/query/subquery_test.exs +++ b/test/ecto/query/subquery_test.exs @@ -474,6 +474,18 @@ defmodule Ecto.Query.SubqueryTest do end end + test "map/1 inside a subquery" do + subquery = from p in Post, select: map(p) + query = normalize(from(p in subquery(subquery), select: p)) + assert query.select.fields == select_fields([:id, :title, :text], 0) + end + + test "map/1 outside a subquery" do + subquery = from p in Post, select: map(p, [:id, :title]) + query = normalize(from(p in subquery(subquery), select: map(p))) + assert query.select.fields == select_fields([:id, :title], 0) + end + test "invalid usage" do assert_raise Ecto.SubQueryError, ~r/does not allow `update` expressions in query/, fn -> query = from p in Post, update: [set: [title: nil]]