Skip to content

Commit 5e45b43

Browse files
authored
Add mix ecto.query task (#733)
Closes elixir-ecto/ecto#4719 Implements a read-only query Mix task run through the selected or default repo. Accepts --sql to render generated SQL and params for the passed query without running it, otherwise returns schema level output. Raises is adapter does not support read only transactions.
1 parent d2668fb commit 5e45b43

2 files changed

Lines changed: 602 additions & 0 deletions

File tree

lib/mix/tasks/ecto.query.ex

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
defmodule Mix.Tasks.Ecto.Query do
2+
use Mix.Task
3+
import Inspect.Algebra
4+
import Mix.Ecto
5+
6+
@shortdoc "Runs a query against the repository"
7+
8+
@switches [
9+
limit: :integer,
10+
repo: [:string, :keep],
11+
sql: :boolean,
12+
no_compile: :boolean,
13+
no_deps_check: :boolean
14+
]
15+
16+
@aliases [
17+
r: :repo
18+
]
19+
20+
@moduledoc """
21+
Runs the given query against the repository.
22+
23+
The query is evaluated as Elixir code after importing `Ecto.Query`.
24+
If a local `.iex.exs` file exists, only aliases from the file are made
25+
available to the query.
26+
27+
The query runs inside a read-only transaction.
28+
29+
## Examples
30+
31+
$ mix ecto.query "from p in Post, where: p.published"
32+
$ mix ecto.query -r Custom.Repo "from p in Post, limit: 10"
33+
$ mix ecto.query --sql "from p in Post, where: p.published"
34+
35+
## Command line options
36+
37+
* `-r`, `--repo` - the repo to query
38+
* `--limit` - limits the number of printed entries. Defaults to 100.
39+
* `--sql` - prints the generated SQL and parameters instead of running the query
40+
41+
"""
42+
43+
@default_limit 100
44+
45+
@impl true
46+
def run(args) do
47+
repos = parse_repo(args)
48+
{opts, query_args} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)
49+
50+
repo =
51+
case repos do
52+
[repo] ->
53+
repo
54+
55+
[] ->
56+
Mix.raise("ecto.query expects a repository to be configured or given as -r MyApp.Repo")
57+
58+
[_ | _] ->
59+
Mix.raise("ecto.query found multiple repositories, please pass one with -r")
60+
end
61+
62+
query =
63+
case query_args do
64+
[query] -> query
65+
[] -> Mix.raise("ecto.query expects a query to be given")
66+
[_ | _] -> Mix.raise("ecto.query expects a single query to be given")
67+
end
68+
69+
limit = Keyword.get(opts, :limit, @default_limit)
70+
71+
if limit < 0 do
72+
Mix.raise("ecto.query expects --limit to be greater than or equal to zero")
73+
end
74+
75+
Mix.Task.run("app.start", args)
76+
ensure_repo(repo, args)
77+
78+
query = eval_query(query)
79+
80+
result =
81+
if opts[:sql] do
82+
{:ok, format_sql(repo, query)}
83+
else
84+
read_only_transaction(repo, fn ->
85+
query
86+
|> repo.all()
87+
|> Enum.take(limit)
88+
|> inspect_entries()
89+
end)
90+
end
91+
92+
result
93+
|> case do
94+
{:ok, output} ->
95+
Mix.shell().info(output)
96+
97+
{:error, reason} ->
98+
Mix.raise("ecto.query failed: #{inspect(reason)}")
99+
end
100+
end
101+
102+
defp eval_query(query) do
103+
query = Code.string_to_quoted!(query, file: "ecto.query")
104+
105+
code =
106+
{:__block__, [],
107+
dot_iex_aliases() ++
108+
[
109+
quote(do: import(Ecto.Query)),
110+
query
111+
]}
112+
113+
{queryable, _binding} = Code.eval_quoted(code, [], file: "ecto.query")
114+
115+
to_query!(queryable)
116+
end
117+
118+
defp to_query!(queryable) do
119+
Ecto.Queryable.to_query(queryable)
120+
rescue
121+
Protocol.UndefinedError ->
122+
Mix.raise(
123+
"Expected ecto.query to evaluate to a queryable expression, got: #{inspect(queryable)}"
124+
)
125+
end
126+
127+
defp dot_iex_aliases do
128+
with true <- File.regular?(".iex.exs"),
129+
{:ok, quoted} <- ".iex.exs" |> File.read!() |> Code.string_to_quoted(file: ".iex.exs") do
130+
collect_aliases(quoted)
131+
else
132+
_ -> []
133+
end
134+
end
135+
136+
defp collect_aliases({:__block__, _, expressions}) do
137+
Enum.filter(expressions, &alias?/1)
138+
end
139+
140+
defp collect_aliases(expression) do
141+
if alias?(expression), do: [expression], else: []
142+
end
143+
144+
defp alias?({:alias, _, [_aliases]}), do: true
145+
defp alias?({:alias, _, [_aliases, _opts]}), do: true
146+
defp alias?(_), do: false
147+
148+
defp read_only_transaction(repo, fun) do
149+
do_read_only_transaction(repo.__adapter__(), repo, fun)
150+
end
151+
152+
defp do_read_only_transaction(Ecto.Adapters.Postgres, repo, fun) do
153+
repo.transaction(fn ->
154+
repo.query!("SET TRANSACTION READ ONLY", [], log: false)
155+
fun.()
156+
end)
157+
end
158+
159+
defp do_read_only_transaction(Ecto.Adapters.MyXQL, repo, fun) do
160+
repo.checkout(fn ->
161+
repo.query!("START TRANSACTION READ ONLY", [], log: false)
162+
163+
try do
164+
{:ok, fun.()}
165+
after
166+
repo.query!("ROLLBACK", [], log: false)
167+
end
168+
end)
169+
end
170+
171+
defp do_read_only_transaction(adapter, _repo, _fun) do
172+
Mix.raise(
173+
"ecto.query requires read-only transactions, which are not supported by #{inspect(adapter)}"
174+
)
175+
end
176+
177+
defp format_sql(repo, query) do
178+
{sql, params} = repo.to_sql(:all, query)
179+
180+
"""
181+
SQL:
182+
#{sql}
183+
184+
Params:
185+
#{inspect(params, limit: :infinity, pretty: true)}
186+
"""
187+
end
188+
189+
defp inspect_entries(entries) do
190+
previous_fun = Inspect.Opts.default_inspect_fun()
191+
192+
inspect_fun = fn
193+
%{__struct__: schema, __meta__: %Ecto.Schema.Metadata{}} = struct, opts ->
194+
inspect_schema(struct, schema, opts)
195+
196+
term, opts ->
197+
previous_fun.(term, opts)
198+
end
199+
200+
inspect(entries, limit: :infinity, pretty: true, inspect_fun: inspect_fun)
201+
end
202+
203+
defp inspect_schema(struct, schema, opts) do
204+
drop_fields =
205+
[:__meta__ | unloaded_associations(schema, struct)] ++ schema.__schema__(:redact_fields)
206+
207+
infos =
208+
for %{field: field} = info <- schema.__info__(:struct),
209+
field not in [:__struct__, :__exception__ | drop_fields],
210+
do: info
211+
212+
inspect_map(struct, Macro.inspect_atom(:literal, schema), infos, opts)
213+
end
214+
215+
defp inspect_map(map, name, infos, opts) do
216+
fun = fn %{field: field}, opts -> inspect_keyword({field, Map.get(map, field)}, opts) end
217+
open = color("%" <> name <> "{", :map, opts)
218+
sep = color(",", :map, opts)
219+
close = color("}", :map, opts)
220+
221+
container_doc(open, infos, close, opts, fun, separator: sep, break: :strict)
222+
end
223+
224+
defp inspect_keyword({key, value}, opts) do
225+
key = color(Macro.inspect_atom(:key, key), :atom, opts)
226+
concat(key, concat(" ", to_doc(value, opts)))
227+
end
228+
229+
defp unloaded_associations(schema, struct) do
230+
for assoc <- schema.__schema__(:associations),
231+
match?(%Ecto.Association.NotLoaded{}, Map.get(struct, assoc)) do
232+
assoc
233+
end
234+
end
235+
end

0 commit comments

Comments
 (0)