Skip to content

Commit 51d43e7

Browse files
committed
Add mix ecto.query task
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 c84f81e commit 51d43e7

2 files changed

Lines changed: 565 additions & 0 deletions

File tree

lib/mix/tasks/ecto.query.ex

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

0 commit comments

Comments
 (0)