Skip to content

Commit

Permalink
Support values lists (#4270)
Browse files Browse the repository at this point in the history
Co-authored-by: narrowtux <narrow.m@gmail.com>
  • Loading branch information
greg-rychlewski and narrowtux authored Aug 26, 2023
1 parent 4c68b14 commit 743ce04
Show file tree
Hide file tree
Showing 12 changed files with 432 additions and 42 deletions.
76 changes: 76 additions & 0 deletions integration_test/cases/repo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -2174,4 +2174,80 @@ defmodule Ecto.Integration.RepoTest do
assert updated_second.public == false
end
end

describe "values list" do
@describetag :values_list

test "all" do
uuid_module = uuid_module(TestRepo.__adapter__())
uuid = uuid_module.generate()

# Without select
values = [%{bid: uuid, visits: 1}, %{bid: uuid, visits: 2}]
types = %{bid: uuid_module, visits: :integer}
query = from v in values(values, types)
assert TestRepo.all(query) == values

# With select
query = select(query, [v], {v, v.bid})
assert TestRepo.all(query) == Enum.map(values, &{&1, &1.bid})
end

test "all with join" do
uuid_module = uuid_module(TestRepo.__adapter__())
uuid = uuid_module.generate()

values1 = [%{bid: uuid, visits: 1}, %{bid: uuid, visits: 2}]
values2 = [%{bid: uuid, visits: 1}]
types = %{bid: uuid_module, visits: :integer}

query =
from v1 in values(values1, types),
join: v2 in values(values2, types),
on: v1.visits == v2.visits

assert TestRepo.all(query) == [%{bid: uuid, visits: 1}]
end

test "delete_all" do
uuid_module = uuid_module(TestRepo.__adapter__())
uuid = uuid_module.generate()

_p1 = TestRepo.insert!(%Post{bid: uuid, visits: 1})
p2 = TestRepo.insert!(%Post{bid: uuid, visits: 5})

values = [%{bid: uuid, visits: 1}, %{bid: nil, visits: 1}, %{bid: uuid, visits: 3}]
types = %{bid: uuid_module, visits: :integer}

query =
from p in Post,
join: v in values(values, types),
on: p.visits == v.visits

assert {1, _} = TestRepo.delete_all(query)
assert TestRepo.all(Post) == [p2]
end

test "update_all" do
uuid_module = uuid_module(TestRepo.__adapter__())
uuid = uuid_module.generate()

TestRepo.insert!(%Post{bid: uuid, visits: 1})

values = [%{bid: uuid, visits: 10}, %{bid: nil, visits: 2}]
types = %{bid: uuid_module, visits: :integer}

query =
from p in Post,
join: v in values(values, types),
on: p.bid == v.bid,
update: [set: [visits: v.visits]]

assert {1, _} = TestRepo.update_all(query, [])
assert [%{visits: 10}] = TestRepo.all(Post)
end

defp uuid_module(Ecto.Adapters.Tds), do: Tds.Ecto.UUID
defp uuid_module(_), do: Ecto.UUID
end
end
53 changes: 53 additions & 0 deletions lib/ecto/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,59 @@ defmodule Ecto.Query do
defstruct [:tag, :type, :value]
end

defmodule Values do
@moduledoc false
defstruct [:types, :num_rows, :params]

def new(values_list, types) do
fields = fields(values_list)
types = types!(fields, types)
params = params!(values_list, types)
%__MODULE__{types: types, params: params, num_rows: length(values_list)}
end

defp fields(values_list) do
fields =
Enum.reduce(values_list, MapSet.new(), fn values, fields ->
Enum.reduce(values, fields, fn {field, _}, fields ->
MapSet.put(fields, field)
end)
end)

MapSet.to_list(fields)
end

defp types!(fields, types) do
Enum.map(fields, fn field->
case types do
%{^field => type} ->
{field, type}

_ ->
raise ArgumentError,
"values/2 must declare the type for every field. " <>
"The type was not given for field `#{field}`"
end
end)
end

defp params!(values_list, types) do
Enum.reduce(values_list, [], fn values, params ->
Enum.reduce(types, params, fn {field, type}, params ->
case values do
%{^field => value} ->
[{value, type} | params]

_ ->
raise ArgumentError,
"each member of a values list must have the same fields. " <>
"Missing field `#{field}` in #{inspect(values)}"
end
end)
end)
end
end

@type t :: %__MODULE__{}
@opaque dynamic_expr :: %DynamicExpr{}

Expand Down
51 changes: 51 additions & 0 deletions lib/ecto/query/api.ex
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,57 @@ defmodule Ecto.Query.API do
"""
def splice(list), do: doc! [list]

@doc """
Creates a values list/constant table.
A values list can be used as a source in a query, both in `Ecto.Query.from/2`
and `Ecto.Query.join/5`.
The first argument is a list of maps representing the values of the constant table.
Each entry in the list must have exactly the same fields or an error is raised.
The second argument is a map of types corresponding to the fields in the first argument.
Each field must be given a type or an error is raised. Any type that can be specified in
a schema may be used.
## Select example
values = [%{id: 1, text: "abc"}, %{id: 2, text: "xyz"}]
types = %{id: :integer, text: :string}
query =
from v1 in values(values, types),
join: v2 in values(values, types),
on: v1.id == v2.id
Repo.all(query)
## Delete example
values = [%{id: 1, text: "abc"}, %{id: 2, text: "xyz"}]
types = %{id: :integer, text: :string}
query =
from p in Post,
join: v in values(values, types),
on: p.id == v.id,
where: p.counter == ^0
Repo.delete_all(query)
## Update example
values = [%{id: 1, text: "abc"}, %{id: 2, text: "xyz"}]
types = %{id: :integer, text: :string}
query =
from p in Post,
join: v in values(values, types),
on: p.id == v.id,
update: [set: [text: v.text]]
Repo.update_all(query, [])
"""
def values(values, types), do: doc! [values, types]

@doc """
Allows a field to be dynamically accessed.
Expand Down
21 changes: 20 additions & 1 deletion lib/ecto/query/builder/from.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,17 @@ defmodule Ecto.Query.Builder.From do

defp escape_source(query, env) do
case Macro.expand_once(query, env) do
{:fragment, _, _} = fragment->
{:fragment, _, _} = fragment ->
{fragment, {params, _acc}} = Builder.escape(fragment, :any, {[], %{}}, [], env)
{fragment, Builder.escape_params(params)}

{:values, _, [values_list, types]} ->
prelude = quote do: values = Ecto.Query.Values.new(unquote(values_list), unquote(types))
types = quote do: values.types
num_rows = quote do: values.num_rows
params = quote do: Ecto.Query.Builder.escape_params(values.params)
{{:{}, [], [:values, [], [types, num_rows]]}, prelude, params}

^query ->
case query do
{left, right} -> {left, Macro.expand(right, env)}
Expand Down Expand Up @@ -111,6 +118,18 @@ defmodule Ecto.Query.Builder.From do
{:ok, prefix} = prefix || {:ok, nil}
{query(prefix, fragment, params, as, hints, env.file, env.line), binds, 1}

{{:{}, _, [:values, _, _]} = values, prelude, params} ->
{:ok, prefix} = prefix || {:ok, nil}
query = query(prefix, values, params, as, hints, env.file, env.line)

quoted =
quote do
unquote(prelude)
unquote(query)
end

{quoted, binds, 1}

_other ->
quoted =
quote do
Expand Down
67 changes: 43 additions & 24 deletions lib/ecto/query/builder/join.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,57 +15,65 @@ defmodule Ecto.Query.Builder.Join do
## Examples
iex> escape(quote(do: x in "foo"), [], __ENV__)
{:x, {"foo", nil}, nil, []}
{:x, {"foo", nil}, nil, nil, []}
iex> escape(quote(do: "foo"), [], __ENV__)
{:_, {"foo", nil}, nil, []}
{:_, {"foo", nil}, nil, nil, []}
iex> escape(quote(do: x in Sample), [], __ENV__)
{:x, {nil, Sample}, nil, []}
{:x, {nil, Sample}, nil, nil, []}
iex> escape(quote(do: x in __MODULE__), [], __ENV__)
{:x, {nil, __MODULE__}, nil, []}
{:x, {nil, __MODULE__}, nil, nil, []}
iex> escape(quote(do: x in {"foo", :sample}), [], __ENV__)
{:x, {"foo", :sample}, nil, []}
{:x, {"foo", :sample}, nil, nil, []}
iex> escape(quote(do: x in {"foo", Sample}), [], __ENV__)
{:x, {"foo", Sample}, nil, []}
{:x, {"foo", Sample}, nil, nil, []}
iex> escape(quote(do: x in {"foo", __MODULE__}), [], __ENV__)
{:x, {"foo", __MODULE__}, nil, []}
{:x, {"foo", __MODULE__}, nil, nil, []}
iex> escape(quote(do: c in assoc(p, :comments)), [p: 0], __ENV__)
{:c, nil, {0, :comments}, []}
{:c, nil, {0, :comments}, nil, []}
iex> escape(quote(do: x in fragment("foo")), [], __ENV__)
{:x, {:{}, [], [:fragment, [], [raw: "foo"]]}, nil, []}
{:x, {:{}, [], [:fragment, [], [raw: "foo"]]}, nil, nil, []}
"""
@spec escape(Macro.t, Keyword.t, Macro.Env.t) :: {atom, Macro.t | nil, Macro.t | nil, list}
def escape({:in, _, [{var, _, context}, expr]}, vars, env)
when is_atom(var) and is_atom(context) do
{_, expr, assoc, params} = escape(expr, vars, env)
{var, expr, assoc, params}
{_, expr, assoc, prelude, params} = escape(expr, vars, env)
{var, expr, assoc, prelude, params}
end

def escape({:subquery, _, [expr]}, _vars, _env) do
{:_, quote(do: Ecto.Query.subquery(unquote(expr))), nil, []}
{:_, quote(do: Ecto.Query.subquery(unquote(expr))), nil, nil, []}
end

def escape({:subquery, _, [expr, opts]}, _vars, _env) do
{:_, quote(do: Ecto.Query.subquery(unquote(expr), unquote(opts))), nil, []}
{:_, quote(do: Ecto.Query.subquery(unquote(expr), unquote(opts))), nil, nil, []}
end

def escape({:fragment, _, [_ | _]} = expr, vars, env) do
{expr, {params, _acc}} = Builder.escape(expr, :any, {[], %{}}, vars, env)
{:_, expr, nil, params}
{:_, expr, nil, nil, params}
end

def escape({:values, _, [values_list, types]}, _vars, _env) do
prelude = quote do: values = Ecto.Query.Values.new(unquote(values_list), unquote(types))
types = quote do: values.types
num_rows = quote do: values.num_rows
params = quote do: values.params
{:_, {:{}, [], [:values, [], [types, num_rows]]}, nil, prelude, params}
end

def escape({string, schema} = join, _vars, env) when is_binary(string) do
case Macro.expand(schema, env) do
schema when is_atom(schema) ->
{:_, {string, schema}, nil, []}
{:_, {string, schema}, nil, nil, []}

_ ->
Builder.error! "malformed join `#{Macro.to_string(join)}` in query expression"
Expand All @@ -77,19 +85,19 @@ defmodule Ecto.Query.Builder.Join do
ensure_field!(field)
var = Builder.find_var!(var, vars)
field = Builder.quoted_atom!(field, "field/2")
{:_, nil, {var, field}, []}
{:_, nil, {var, field}, nil, []}
end

def escape({:^, _, [expr]}, _vars, _env) do
{:_, quote(do: Ecto.Query.Builder.Join.join!(unquote(expr))), nil, []}
{:_, quote(do: Ecto.Query.Builder.Join.join!(unquote(expr))), nil, nil, []}
end

def escape(string, _vars, _env) when is_binary(string) do
{:_, {string, nil}, nil, []}
{:_, {string, nil}, nil, nil, []}
end

def escape(schema, _vars, _env) when is_atom(schema) do
{:_, {nil, schema}, nil, []}
{:_, {nil, schema}, nil, nil, []}
end

def escape(join, vars, env) do
Expand Down Expand Up @@ -147,8 +155,8 @@ defmodule Ecto.Query.Builder.Join do
end

{query, binding} = Builder.escape_binding(query, binding, env)
{join_bind, join_source, join_assoc, join_params} = escape(expr, binding, env)
join_params = Builder.escape_params(join_params)
{join_bind, join_source, join_assoc, join_prelude, join_params} = escape(expr, binding, env)
join_params = escape_params(join_params)

join_qual = validate_qual(qual)
validate_bind(join_bind, binding)
Expand Down Expand Up @@ -188,13 +196,22 @@ defmodule Ecto.Query.Builder.Join do
]

on = ensure_on(on, join_assoc, join_qual, join_source, env)
query = build_on(on, join, as, query, binding, count_bind, env)
query = build_on(on, join_prelude, join, as, query, binding, count_bind, env)
{query, binding, next_bind}
end

def build_on({:^, _, [var]}, join, as, query, _binding, count_bind, env) do
defp escape_params(params) when is_list(params) do
Builder.escape_params(params)
end

defp escape_params(params) do
quote do: Builder.escape_params(unquote(params))
end

def build_on({:^, _, [var]}, join_prelude, join, as, query, _binding, count_bind, env) do
quote do
query = unquote(query)
unquote(join_prelude)

Ecto.Query.Builder.Join.join!(
query,
Expand All @@ -208,7 +225,7 @@ defmodule Ecto.Query.Builder.Join do
end
end

def build_on(on, join, as, query, binding, count_bind, env) do
def build_on(on, join_prelude, join, as, query, binding, count_bind, env) do
case Ecto.Query.Builder.Filter.escape(:on, on, count_bind, binding, env) do
{_on_expr, {_on_params, %{subqueries: [_ | _]}}} ->
raise ArgumentError, "invalid expression for join `:on`, subqueries aren't supported"
Expand All @@ -218,6 +235,8 @@ defmodule Ecto.Query.Builder.Join do

join =
quote do
unquote(join_prelude)

%JoinExpr{
unquote_splicing(join),
on: %QueryExpr{
Expand Down
Loading

0 comments on commit 743ce04

Please sign in to comment.