-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Add mix ecto.query task #4731
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Add mix ecto.query task #4731
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,164 @@ | ||
| defmodule Mix.Tasks.Ecto.Query do | ||
| use Mix.Task | ||
| import Mix.Ecto | ||
|
|
||
| @shortdoc "Runs a query against the repository" | ||
|
|
||
| @switches [ | ||
| limit: :integer, | ||
| repo: [:string, :keep], | ||
| no_compile: :boolean, | ||
| no_deps_check: :boolean | ||
| ] | ||
|
|
||
| @aliases [ | ||
| r: :repo | ||
| ] | ||
|
|
||
| @moduledoc """ | ||
| Runs the given query against the repository. | ||
|
|
||
| The query is evaluated as Elixir code after loading the current | ||
| `.iex.exs` file, if one exists, and importing `Ecto.Query`. | ||
|
|
||
| ## Examples | ||
|
|
||
| $ mix ecto.query "from p in Post, where: p.published" | ||
| $ mix ecto.query -r Custom.Repo "from p in Post, limit: 10" | ||
|
|
||
| ## Command line options | ||
|
|
||
| * `-r`, `--repo` - the repo to query | ||
| * `--limit` - limits the number of printed entries. Defaults to 100. | ||
|
|
||
| """ | ||
|
|
||
| @default_limit 100 | ||
|
|
||
| @impl true | ||
| def run(args) do | ||
| repos = parse_repo(args) | ||
| {opts, query_args} = OptionParser.parse!(args, strict: @switches, aliases: @aliases) | ||
|
|
||
| repo = | ||
| case repos do | ||
| [repo] -> repo | ||
| [] -> Mix.raise("ecto.query expects a repository to be configured or given as -r MyApp.Repo") | ||
| [_ | _] -> Mix.raise("ecto.query found multiple repositories, please pass one with -r") | ||
| end | ||
|
|
||
| query = | ||
| case query_args do | ||
| [query] -> query | ||
| [] -> Mix.raise("ecto.query expects a query to be given") | ||
| [_ | _] -> Mix.raise("ecto.query expects a single query to be given") | ||
| end | ||
|
|
||
| limit = Keyword.get(opts, :limit, @default_limit) | ||
|
|
||
| if limit < 0 do | ||
| Mix.raise("ecto.query expects --limit to be greater than or equal to zero") | ||
| end | ||
|
|
||
| Mix.Task.run("app.start", args) | ||
| ensure_repo(repo, args) | ||
|
|
||
| query = eval_query(query) | ||
|
|
||
| repo.transaction( | ||
| fn -> | ||
| query | ||
| |> repo.all() | ||
| |> Enum.take(limit) | ||
| end, | ||
| read_only: true | ||
| ) | ||
| |> case do | ||
| {:ok, entries} -> | ||
| entries | ||
| |> clean_entries() | ||
| |> inspect(limit: :infinity, pretty: true) | ||
| |> Mix.shell().info() | ||
|
|
||
| {:error, reason} -> | ||
| Mix.raise("ecto.query failed: #{inspect(reason)}") | ||
| end | ||
| end | ||
|
|
||
| defp eval_query(query) do | ||
| code = [dot_iex(), "\nimport Ecto.Query\n", query] | ||
|
|
||
| {queryable, _binding} = | ||
| code | ||
| |> IO.iodata_to_binary() | ||
| |> Code.eval_string([], file: "ecto.query") | ||
|
|
||
| to_query!(queryable) | ||
| end | ||
|
|
||
| defp to_query!(queryable) do | ||
| Ecto.Queryable.to_query(queryable) | ||
| rescue | ||
| Protocol.UndefinedError -> | ||
| Mix.raise( | ||
| "Expected ecto.query to evaluate to a queryable expression, got: #{inspect(queryable)}" | ||
| ) | ||
| end | ||
|
|
||
| defp dot_iex do | ||
| if File.regular?(".iex.exs") do | ||
| [File.read!(".iex.exs"), "\n"] | ||
| else | ||
| [] | ||
| end | ||
| end | ||
|
|
||
| defp clean_entries(entries) do | ||
| Enum.map(entries, &clean_entry/1) | ||
| end | ||
|
|
||
| defp clean_entry(%{__struct__: schema} = struct) do | ||
| if function_exported?(schema, :__schema__, 1) do | ||
| drop_fields = [ | ||
| :__meta__ | schema.__schema__(:associations) ++ schema.__schema__(:redact_fields) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if associations should only be deleted if they aren't preloaded
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's a good point. I interpreted the issue as dropping all association fields, but keeping preloaded associations is more useful. I’ll change it to keep preloaded associations. |
||
| ] | ||
|
|
||
| fields = | ||
| struct | ||
| |> Map.from_struct() | ||
| |> Map.drop(drop_fields) | ||
| |> Enum.map(fn {key, value} -> {key, clean_entry(value)} end) | ||
| |> Enum.sort() | ||
|
|
||
| struct(Mix.Tasks.Ecto.Query.Schema, schema: schema, fields: fields) | ||
| else | ||
| struct | ||
| end | ||
| end | ||
|
|
||
| defp clean_entry(entries) when is_list(entries), do: Enum.map(entries, &clean_entry/1) | ||
|
|
||
| defp clean_entry(%{} = entry), | ||
| do: Map.new(entry, fn {key, value} -> {key, clean_entry(value)} end) | ||
|
|
||
| defp clean_entry(entry), do: entry | ||
| end | ||
|
|
||
| defmodule Mix.Tasks.Ecto.Query.Schema do | ||
| @moduledoc false | ||
|
|
||
| defstruct [:schema, :fields] | ||
| end | ||
|
Comment on lines
+147
to
+151
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why does this struct need to exist? I think it would probably be nicer to rely on the existing inspect implementation for the structs...
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is only there to keep schema shaped output after dropping |
||
|
|
||
| defimpl Inspect, for: Mix.Tasks.Ecto.Query.Schema do | ||
| import Inspect.Algebra | ||
|
|
||
| def inspect(%{schema: schema, fields: fields}, opts) do | ||
| docs = | ||
| Enum.map(fields, fn {key, value} -> | ||
| concat([Atom.to_string(key), ": ", to_doc(value, opts)]) | ||
| end) | ||
|
|
||
| container_doc("%#{inspect(schema)}{", docs, "}", opts, fn doc, _opts -> doc end) | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| defmodule Mix.Tasks.Ecto.QueryTest do | ||
| use ExUnit.Case | ||
|
|
||
| alias Mix.Tasks.Ecto.Query | ||
|
|
||
| defmodule Comment do | ||
| use Ecto.Schema | ||
|
|
||
| schema "comments" do | ||
| field :text, :string | ||
| end | ||
| end | ||
|
|
||
| defmodule Profile do | ||
| use Ecto.Schema | ||
|
|
||
| embedded_schema do | ||
| field :bio, :string | ||
| field :token, :string, redact: true | ||
| end | ||
| end | ||
|
|
||
| defmodule Post do | ||
| use Ecto.Schema | ||
|
|
||
| schema "posts" do | ||
| field :title, :string | ||
| field :secret, :string, redact: true | ||
| embeds_one :profile, Profile | ||
| has_many :comments, Comment | ||
| end | ||
| end | ||
|
|
||
| setup do | ||
| Process.delete(:test_repo_all_results) | ||
| Application.put_env(:ecto, :ecto_repos, [Ecto.TestRepo]) | ||
| :ok | ||
| end | ||
|
|
||
| test "runs a query against the repo in a read-only transaction" do | ||
| Process.put( | ||
| :test_repo_all_results, | ||
| {2, | ||
| [ | ||
| [1, "first", "hunter2", %{id: "profile-1", bio: "hello", token: "profile-token"}], | ||
| [2, "second", "swordfish", %{id: "profile-2", bio: "world", token: "profile-secret"}] | ||
| ]} | ||
| ) | ||
|
|
||
| in_tmp("read_only", fn -> | ||
| File.write!(".iex.exs", "alias #{inspect(Post)}\n") | ||
|
|
||
| Query.run(["-r", "Ecto.TestRepo", "from(p in Post)"]) | ||
|
|
||
| assert_received {:transaction, _fun, opts} | ||
| assert opts[:read_only] | ||
|
|
||
| assert_received {:all, %Ecto.Query{}} | ||
| assert_received {:mix_shell, :info, [output]} | ||
|
|
||
| assert output =~ "%Mix.Tasks.Ecto.QueryTest.Post{" | ||
| assert output =~ ~s(title: "first") | ||
| assert output =~ "%Mix.Tasks.Ecto.QueryTest.Profile{" | ||
| assert output =~ ~s(bio: "hello") | ||
| refute output =~ "__meta__" | ||
| refute output =~ "comments:" | ||
| refute output =~ "secret:" | ||
| refute output =~ "hunter2" | ||
| refute output =~ "token:" | ||
| refute output =~ "profile-token" | ||
| end) | ||
| end | ||
|
|
||
| test "uses the configured default repo" do | ||
| Process.put(:test_repo_all_results, {1, [[1, "first", "hunter2", nil]]}) | ||
|
|
||
| in_tmp("default_repo", fn -> | ||
| File.write!(".iex.exs", "alias #{inspect(Post)}\n") | ||
|
|
||
| Query.run(["from(p in Post)"]) | ||
|
|
||
| assert_received {:transaction, _fun, opts} | ||
| assert opts[:read_only] | ||
| end) | ||
| end | ||
|
|
||
| test "accepts a schema module queryable" do | ||
| Process.put(:test_repo_all_results, {1, [[1, "first", "hunter2", nil]]}) | ||
|
|
||
| Query.run(["-r", "Ecto.TestRepo", inspect(Post)]) | ||
|
|
||
| assert_received {:all, %Ecto.Query{}} | ||
| assert_received {:mix_shell, :info, [output]} | ||
| assert output =~ ~s(title: "first") | ||
| end | ||
|
|
||
| test "limits printed entries" do | ||
| Process.put( | ||
| :test_repo_all_results, | ||
| {2, [[1, "first", "hunter2", nil], [2, "second", "swordfish", nil]]} | ||
| ) | ||
|
|
||
| in_tmp("limit", fn -> | ||
| File.write!(".iex.exs", "alias #{inspect(Post)}\n") | ||
|
|
||
| Query.run(["-r", "Ecto.TestRepo", "--limit", "1", "from(p in Post)"]) | ||
|
|
||
| assert_received {:mix_shell, :info, [output]} | ||
| assert output =~ ~s(title: "first") | ||
| refute output =~ ~s(title: "second") | ||
| end) | ||
| end | ||
|
|
||
| test "raises when the evaluated expression is not queryable" do | ||
| for query <- ["1", "%{}", "[]", ":ok"] do | ||
| assert_raise Mix.Error, | ||
| ~r/Expected ecto\.query to evaluate to a queryable expression, got:/, | ||
| fn -> | ||
| Query.run(["-r", "Ecto.TestRepo", query]) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| test "raises when multiple repos are given" do | ||
| assert_raise Mix.Error, | ||
| "ecto.query found multiple repositories, please pass one with -r", | ||
| fn -> | ||
| Query.run(["-r", "Ecto.TestRepo", "-r", "Ecto.TestRepo", "from(p in Post)"]) | ||
| end | ||
| end | ||
|
|
||
| test "raises when a query is not given" do | ||
| assert_raise Mix.Error, "ecto.query expects a query to be given", fn -> | ||
| Query.run(["-r", "Ecto.TestRepo"]) | ||
| end | ||
| end | ||
|
|
||
| @tmp_path Path.expand("../../../tmp", __DIR__) | ||
|
|
||
| defp in_tmp(path, fun) do | ||
| path = Path.join(@tmp_path, path) | ||
| File.rm_rf!(path) | ||
| File.mkdir_p!(path) | ||
| File.cd!(path, fun) | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like this option conceptually, but the need to add it makes me wonder if the whole thing should be in
ecto_sql🤔There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I had the same concern. I started here because the command evaluates an Ecto query and calls
Repo.all, so the schema-level output felt like the more natural first pass.The
read_only: truepart is the bit that crosses into adapter behavior. My thinking was to document and pass the option through in Ecto, then handle the actual SQL adapter support in anecto_sqlfollow-up, together with the--sqloption.