lib/mix/tasks/ecto.gen.queries.ex

defmodule Mix.Tasks.Ecto.Gen.Queries do
  @moduledoc """
  Task for running a generator to write query functions. All query functions are designed to be composable,
  as in a query is required as the first argument. The task takes command line options to control
  generating functions to only what is necessary. If a function already exists with the same name
  as a generated function, it will be skipped. The exception for this is if a sort function is marked as
  one to keep, using the comment syntax `# schema_generator:keep_next_function`. This function will be kept,
  with other sort functions generated as usual. Any of the overridden functions will be added after the
  generated sort functions. This operation is not completely reversible, as it removes specs from sort functions
  that are then grouped with the generated sort functions.

  ## Command line options
    * {files_or_directory} - which file or files to generate query functions for

    * `--skip-fields` - won't generate field based query functions, aka by_field(query, field)

    * `--skip-assocs` - won't generate assoc based query functions, aka with_assoc(query)

    * `--skip-sort` - won't generate field sorting query functions, aka sort(query, "field_asc")

    * `--primary-key {string}` - allows overriding the default primary key function generation when the pk isn't found in the file

    * `--quiet` - runs without logging

    * `--ci` - runs a validation for ci, if any files change it will generate an error
  """
  defmodule SchemaMeta do
    @moduledoc false
    defstruct primary_key: nil, functions: [], version: nil
  end

  use Mix.Task
  alias Sourceror.Zipper

  @preferred_cli_env :dev
  @shortdoc "Writes by_*, with_*, and sort functions based on the schema struct"
  @switches [
    skip_fields: :boolean,
    skip_assocs: :boolean,
    skip_sort: :boolean,
    quiet: :boolean,
    ci: :boolean,
    primary_key: :string
  ]
  @primary_key_default "id"
  @default_function_order [:with_queries, :by_queries, :sort_queries]

  @impl true
  def run(args) do
    defaults = [
      skip_fields: false,
      skip_assocs: false,
      skip_sort: false,
      quiet: false,
      ci: false,
      primary_key: @primary_key_default
    ]

    {options, path} = OptionParser.parse!(args, strict: @switches)
    opts_with_defaults = defaults |> Keyword.merge(options) |> Enum.into(%{})

    results = generate(path, opts_with_defaults)

    if opts_with_defaults.ci and Enum.uniq(results) != [:noop] do
      log(:red, :ci_failure, "some new query functions were generated or errors occurred", %{
        quiet: false
      })

      cmd_args = args |> Enum.reject(& &1 == "--ci") |> Enum.join(" ")

      log(:yellow, :ci_warning, "please run `mix ecto.gen.queries #{cmd_args}` and commit", %{
        quiet: false
      })

      exit({:shutdown, 1})
    end
  end

  def generate(path_or_files, options) when is_list(path_or_files) do
    Enum.flat_map(path_or_files, &generate(&1, options))
  end

  def generate(path_or_file, options) do
    if File.dir?(path_or_file) do
      with {:ok, files} <- File.ls(path_or_file) do
        Enum.flat_map(files, fn file -> generate(path_or_file <> "/" <> file, options) end)
      end
    else
      case check_filename(path_or_file) do
        :ok ->
          [generate_query_functions(path_or_file, options)]

        {:error, reason} ->
          log(:red, :skipping, "because #{path_or_file} is #{reason}", options)

          [:noop]
      end
    end
  end

  defp check_filename(filename) do
    cond do
      not String.ends_with?(filename, ".ex") ->
        {:error, "not a valid elixir file"}

      not File.exists?(filename) ->
        {:error, "not a file"}

      true ->
        :ok
    end
  end

  @spec generate_query_functions(binary(), map()) :: :error | :generated | :noop
  def generate_query_functions(filename, options) do
    log(:green, :generating, "schema functions for #{filename}", options)

    filestring = File.read!(filename)

    generated_regex = ~r/\@schema_gen_tag .*\n/

    precleaned_ast = Sourceror.parse_string!(filestring)
    {kept_sorts_removed_ast, existing_sorts} = extract_existing_sort_functions(precleaned_ast)

    kept_sorts_removed_string = Sourceror.to_string(kept_sorts_removed_ast)

    cleaned_filestring =
      case Regex.split(generated_regex, kept_sorts_removed_string) do
        [start, _, finish] ->
          new_start =
            String.replace(
              start,
              "Module.register_attribute(__MODULE__, :schema_gen_tag, accumulate: true)\n",
              ""
            )

          new_start <> finish

        _ ->
          kept_sorts_removed_string
      end

    {_, version} =
      Macro.prewalk(precleaned_ast, nil, fn
        {:@, _meta1,
         [
           {:schema_gen_tag, _meta2, [{:__block__, _block_meta, [version]}]}
         ]} = ast,
        _acc ->
          {ast, version}

        other, acc ->
          {other, acc}
      end)

    cleaned_ast = Sourceror.parse_string!(cleaned_filestring)

    with {:defmodule, module_meta,
          [
            {:__aliases__, alias_meta, module},
            [{{:__block__, _do_block_meta, [:do]}, {:__block__, _block_meta, module_children}}]
          ]} = ast <- cleaned_ast do
      {_, schema_meta} =
        Macro.prewalk(ast, %SchemaMeta{version: version}, fn
          {:@, _meta1, [{:primary_key, _meta2, [{:__block__, _meta3, [primary_key]}]}]} = ast,
          acc ->
            acc = if primary_key, do: Map.replace(acc, :primary_key, primary_key), else: acc
            {ast, acc}

          {:def, _meta1, [{:when, _meta2, [{fun_name, _, _} | _]} | _]} = ast,
          %{functions: functions} = acc ->
            {ast, Map.replace(acc, :functions, [fun_name | functions])}

          {:def, _meta1, [{fun_name, _meta2, _children} | _]} = ast,
          %{functions: functions} = acc ->
            {ast, Map.replace(acc, :functions, [fun_name | functions])}

          other, acc ->
            {other, acc}
        end)

      uniq_functions_schema_meta = Map.update!(schema_meta, :functions, &Enum.uniq/1)

      {_ast, generated_functions} =
        Macro.prewalk(ast, [], fn
          {:schema, _meta,
           [
             {:__block__, _block_meta, [schema_name]},
             [{{:__block__, _do_block_meta, [:do]}, _} | _]
           ]} = schema_block,
          _acc ->
            function_gen(schema_block, schema_name, uniq_functions_schema_meta, existing_sorts, options)

          other, acc ->
            {other, acc}
        end)

      sorted_generated_functions =
        @default_function_order
        |> Enum.map(fn function_class ->
          generated_functions
          |> List.flatten()
          |> Keyword.get(function_class, [])
          |> Enum.reverse()
        end)
        |> List.flatten()

      if sorted_generated_functions == [] do
        log(:red, :skipping, "because #{filename} has no generated functions", options)

        :noop
      else
        old_version = schema_meta.version

        version =
          :md5 |> :crypto.hash(Sourceror.to_string(sorted_generated_functions)) |> Base.encode64()

        if old_version == version do
          log(:red, :skipping, "because #{filename} has no schema version changes", options)

          :noop
        else
          new_ast =
            {:defmodule, module_meta,
             [
               {:__aliases__, alias_meta, module},
               [
                 do:
                   {:__block__, [],
                    List.flatten([
                      module_children
                      | [
                          leading_tag(version),
                          sorted_generated_functions,
                          generate_version_function(),
                          trailing_tag(version)
                        ]
                    ])}
               ]
             ]}

          case Macro.validate(new_ast) do
            :ok ->
              unless options.ci do
                string = Sourceror.to_string(new_ast)

                File.write!(filename, string <> "\n")
              end

              :generated

            error ->
              log(
                :red,
                :skipping,
                "because #{filename} has generated invalid ast: #{inspect(error)}",
                options
              )

              :error
          end
        end
      end
    else
      _ ->
        log(:red, :skipping, "because #{filename} is not readable", options)

        :error
    end
  end

  defp function_gen(schema_block, string_schema, schema_meta, existing_sorts, options) do
    atom_schema = string_schema |> Inflex.singularize() |> String.to_atom()

    {_, field_meta} =
      Macro.prewalk(schema_block, %{fields: [], primary_key: nil}, fn
        {:field, _meta, [{:__block__, _block_meta, [field]}, _type_block, options_block]} =
            original,
        %{fields: fields} = acc ->
          if field_is_virtual?(options_block) do
            {original, acc}
          else
            acc =
              if field_is_primary_key?(options_block),
                do: Map.replace(acc, :primary_key, field),
                else: acc

            {original, Map.replace(acc, :fields, [field | fields])}
          end

        {:field, _meta, [{:__block__, _block_meta, [field]} | _]} = original,
        %{fields: fields} = acc ->
          {original, Map.replace(acc, :fields, [field | fields])}

        {:belongs_to, _meta,
         [
           {:__block__, _assoc_block_meta, [_assoc]},
           {:__aliases__, _alias_meta, _alias},
           options_block
         ]} = original,
        %{fields: fields} = acc ->
          foreign_key = find_foreign_key(options_block)
          {original, Map.replace(acc, :fields, [foreign_key | fields])}

        {:timestamps, _meta, []} = original, %{fields: fields} = acc ->
          {original, Map.replace(acc, :fields, [:updated_at, :inserted_at | fields])}

        {:timestamps, _meta, [timestamp_columns | _]} = original, %{fields: fields} = acc ->
          {original,
           Map.replace(acc, :fields, select_timestamp_columns(timestamp_columns) ++ fields)}

        other, acc ->
          {other, acc}
      end)

    {existing_sort_funs, existing_sort_args} = Enum.map_reduce(existing_sorts, [], fn {sort_arg, sort_fun}, acc ->
      {sort_fun, [sort_arg | acc]}
    end)

    existing_sort_funs_flat = existing_sort_funs |> List.flatten() |> Enum.reverse()
    existing_sort_args_non_nil = Enum.filter(existing_sort_args, & &1)

    sort_functions =
      existing_sort_funs_flat ++ generate_sort_queries(field_meta.fields, atom_schema, schema_meta.functions, existing_sort_args_non_nil, options)

    primary_key_function =
      if is_nil(field_meta.primary_key),
        do: generate_primary_key_query(schema_meta.primary_key, schema_meta.functions, options),
        else: []

    query_kw = [
      sort_queries: sort_functions,
      by_queries: [primary_key_function],
      with_queries: []
    ]

    Macro.prewalk(schema_block, query_kw, fn
      {:field, _meta, [{:__block__, _block_meta, [field]}, _type_block, options_block]} = original,
      acc ->
        if field_is_virtual?(options_block) do
          {original, acc}
        else
          {original,
           update_function_kw(
             acc,
             :by_queries,
             generate_by_query(field, schema_meta.functions, options)
           )}
        end

      {:field, _meta, [{:__block__, _block_meta, [field]} | _]} = original, acc ->
        {original,
         update_function_kw(
           acc,
           :by_queries,
           generate_by_query(field, schema_meta.functions, options)
         )}

      {:timestamps, _meta, []} = original, acc ->
        {original,
         acc
         |> update_function_kw(
           :by_queries,
           generate_by_query(:updated_at, schema_meta.functions, options)
         )
         |> update_function_kw(
           :by_queries,
           generate_by_query(:inserted_at, schema_meta.functions, options)
         )}

      {:timestamps, _meta, [timestamp_columns | _]} = original, acc ->
        {original,
         timestamp_columns
         |> select_timestamp_columns()
         |> Enum.map(
           &update_function_kw(
             acc,
             :by_queries,
             generate_by_query(&1, schema_meta.functions, options)
           )
         )}

      {:belongs_to, _meta,
       [
         {:__block__, _assoc_block_meta, [assoc]},
         {:__aliases__, _alias_meta, _alias},
         options_block
       ]} = original,
      acc ->
        foreign_key = find_foreign_key(options_block)

        {original,
         acc
         |> update_function_kw(
           :with_queries,
           generate_with_query(atom_schema, assoc, schema_meta.functions, options)
         )
         |> update_function_kw(
           :by_queries,
           generate_by_query(foreign_key, schema_meta.functions, options)
         )}

      {:has_many, _meta, [{:__block__, _block_meta, [assoc]} | _other]} = original, acc ->
        {original,
         update_function_kw(
           acc,
           :with_queries,
           generate_with_query(atom_schema, assoc, schema_meta.functions, options)
         )}

      {:has_one, _meta, [{:__block__, _block_meta, [assoc]} | _other]} = original, acc ->
        {original,
         update_function_kw(
           acc,
           :with_queries,
           generate_with_query(atom_schema, assoc, schema_meta.functions, options)
         )}

      {:many_to_many, _meta, [{:__block__, _block_meta, [assoc]} | _other]} = original, acc ->
        {original,
         update_function_kw(
           acc,
           :with_queries,
           generate_with_query(atom_schema, assoc, schema_meta.functions, options)
         )}

      other, acc ->
        {other, acc}
    end)
  end

  defp find_foreign_key(options_block) do
    Enum.reduce_while(options_block, nil, fn
      {{:__block__, _block_meta1, [:foreign_key]}, {:__block__, _block_meta2, [foreign_key]}},
      _ ->
        {:halt, foreign_key}

      _, acc ->
        {:cont, acc}
    end)
  end

  defp field_is_virtual?(options_block) do
    Enum.reduce_while(options_block, false, fn
      {{:__block__, _block_meta1, [:virtual]}, {:__block__, _block_meta2, [true]}}, _ ->
        {:halt, true}

      _, acc ->
        {:cont, acc}
    end)
  end

  defp field_is_primary_key?(options_block) do
    Enum.reduce_while(options_block, false, fn
      {{:__block__, _block_meta1, [:primary_key]}, {:__block__, _block_meta2, [true]}}, _ ->
        {:halt, true}

      _, acc ->
        {:cont, acc}
    end)
  end

  defp extract_existing_sort_functions(ast) do
    {zipper, accumulated_sorts} =
      ast
      |> Zipper.zip()
      |> Zipper.traverse([], fn
        %Zipper{node: {:@, [_trailing_comments, {:leading_comments, comments = [%{text: "# schema_generator:keep_next_function"}]} | _], [{_name, _meta2, _args} | _]}} = zipper, acc ->
          function = Zipper.right(zipper)
          if is_sort_function?(function) do
            function_node_with_comments = function |> Zipper.node() |> Sourceror.prepend_comments(comments, :leading)
            clean_zipper = zipper |> Zipper.remove() |> Zipper.next() |> Zipper.remove()
            {clean_zipper, acc ++ [{get_filter_arg(function), [function_node_with_comments]}]}
          else
            {zipper, acc}
          end

        %Zipper{node: {:def, [_trailing_comments, {:leading_comments, [%{text: "# schema_generator:keep_next_function"}]} | _], [{_name, _meta2, _args} | _]}} = zipper, acc ->
          maybe_spec = Zipper.left(zipper)
          clean_zipper = if spec?(maybe_spec) do
            zipper |> Zipper.left() |> Zipper.remove() |> Zipper.next() |> Zipper.remove()
          else
            Zipper.remove(zipper)
          end

          {clean_zipper, acc ++ [{get_filter_arg(zipper), [Zipper.node(zipper)]}]}

        other, acc ->
          {other, acc}
      end)

    {Zipper.node(zipper), accumulated_sorts}
  end

  def is_sort_function?(%Zipper{node: {:def, _meta1, [{:sort, _meta2, _args} | _]}}) do
    true
  end

  def is_sort_function?(%Zipper{node: {:def, _meta1, [{:when, _meta2, [{:sort, _meta3, _args} | _]} | _]}}) do
    true
  end

  def is_sort_function?(_other) do
    false
  end

  defp get_filter_arg(%Zipper{node: {:def, _meta1, [{:sort, _meta2, [_first_arg, {:__block__, _meta3, [filter_arg]}]} | _]}}) do
    filter_arg
  end

  defp get_filter_arg(_other) do
    nil
  end

  defp spec?(%Zipper{node: {:@, _meta1, [{:spec, _meta2, _spec}]}}) do
    true
  end

  defp spec?(_) do
    false
  end

  defp generate_primary_key_query(_primary_key, _functions, %{skip_fields: true}), do: []

  defp generate_primary_key_query(nil, functions, %{primary_key: primary_key} = options) do
    atomized_key = String.to_atom(primary_key)
    generate_by_query(atomized_key, functions, options)
  end

  defp generate_primary_key_query(primary_key, functions, options) do
    atomized_key = String.to_atom(primary_key)
    generate_by_query(atomized_key, functions, options)
  end

  defp generate_by_query(_field, _functions, %{skip_fields: true}), do: []

  defp generate_by_query(field, functions, _) do
    if Enum.member?(functions, :"by_#{field}") do
      []
    else
      [
        {:@, [],
         [
           {:spec, [],
            [
              {:"::", [],
               [
                 {:"by_#{field}", [],
                  [
                    {{:., [], [{:__aliases__, [], [:Ecto, :Queryable]}, :t]}, [], []},
                    {:any, [], nil}
                  ]},
                 {{:., [], [{:__aliases__, [], [:Ecto, :Queryable]}, :t]}, [], []}
               ]}
            ]}
         ]},
        {:def, [],
         [
           {:when, [],
            [
              {:"by_#{field}", [], [{:query, [], nil}, {:query_by, [], nil}]},
              {:is_list, [], [{:query_by, [], nil}]}
            ]},
           [
             do:
               {:from, [],
                [
                  {:in, [], [{:record, [], nil}, {:query, [], nil}]},
                  [
                    where:
                      {:in, [],
                       [
                         {{:., [], [{:record, [], nil}, field]}, [no_parens: true], []},
                         {:^, [], [{:query_by, [], nil}]}
                       ]}
                  ]
                ]}
           ]
         ]},
        {:def, [],
         [
           {:"by_#{field}", [], [{:query, [], nil}, nil]},
           [
             do:
               {:from, [],
                [
                  {:in, [], [{:record, [], nil}, {:query, [], nil}]},
                  [
                    where:
                      {:is_nil, [],
                       [
                         {{:., [], [{:record, [], nil}, field]}, [no_parens: true], []}
                       ]}
                  ]
                ]}
           ]
         ]},
        {:def, [],
         [
           {:"by_#{field}", [], [{:query, [], nil}, {:query_by, [], nil}]},
           [
             do:
               {:from, [],
                [
                  {:in, [], [{:record, [], nil}, {:query, [], nil}]},
                  [
                    where:
                      {:==, [],
                       [
                         {{:., [], [{:record, [], nil}, field]}, [no_parens: true], []},
                         {:^, [], [{:query_by, [], nil}]}
                       ]}
                  ]
                ]}
           ]
         ]}
      ]
    end
  end

  defp generate_sort_queries([], _schema, _functions, _existing_sort_args, _opts), do: []
  defp generate_sort_queries(_fields, _schema, _functions, _existing_sort_args, %{skip_sort: true}), do: []

  defp generate_sort_queries(fields, schema, functions, existing_sort_args, _opts) do
    if Enum.member?(functions, :sort) do
      []
    else
      [
        {:@, [],
         [
           {:spec, [],
            [
              {:"::", [],
               [
                 {:sort, [],
                  [
                    {{:., [], [{:__aliases__, [], [:Ecto, :Queryable]}, :t]}, [], []},
                    {:|, [], [{{:., [], [{:__aliases__, [], [:String]}, :t]}, [], []}, nil]}
                  ]},
                 {{:., [], [{:__aliases__, [], [:Ecto, :Queryable]}, :t]}, [], []}
               ]}
            ]}
         ]},
        {:def, [],
         [
           {:sort, [], [{:query, [], nil}, {:__block__, [], [nil]}]},
           [
             {{:__block__, [format: :keyword], [:do]}, {:query, [], nil}}
           ]
         ]},
        fields
        |> Enum.flat_map(fn field ->
          [
            generate_sort_query(schema, field, :asc, existing_sort_args),
            generate_sort_query(schema, field, :desc, existing_sort_args)
          ]
        end)
        |> Enum.reverse()
      ]
      |> Enum.reverse()
    end
  end

  defp generate_sort_query(schema, field, direction, existing_sort_args) do
    sort_name = "#{field}_#{Atom.to_string(direction)}"

    if sort_name in existing_sort_args do
      []
    else
      {:def, [],
      [
        {:sort, [], [{:query, [], nil}, sort_name]},
        [
          do:
            {:order_by, [],
            [
              {:query, [], nil},
              [{schema, {:schema_record, [], nil}}],
              [
                {direction,
                  {{:., [], [{:schema_record, [], nil}, field]}, [no_parens: true], []}}
              ]
            ]}
        ]
      ]}
    end

  end

  defp generate_with_query(_schema, _assoc, _functions, %{skip_assocs: true}), do: []

  defp generate_with_query(schema, assoc, functions, _opts) do
    if Enum.member?(functions, :"with_#{assoc}") do
      []
    else
      [
        {:@, [],
         [
           {:spec, [],
            [
              {:"::", [],
               [
                 {:"with_#{assoc}", [],
                  [
                    {{:., [], [{:__aliases__, [], [:Ecto, :Queryable]}, :t]}, [], []},
                    {{:., [], [{:__aliases__, [], [:Keyword]}, :t]}, [], []}
                  ]},
                 {{:., [], [{:__aliases__, [], [:Ecto, :Queryable]}, :t]}, [], []}
               ]}
            ]}
         ]},
        {:def, [],
         [
           {:"with_#{assoc}", [],
            [
              {:query, [], nil},
              {:\\, [], [{:opts, [], nil}, []]}
            ]},
           [
             do:
               {:__block__, [],
                [
                  {:=, [],
                   [
                     {:join_type, [], nil},
                     {{:., [], [{:__aliases__, [], [:Keyword]}, :get]}, [],
                      [{:opts, [], nil}, :join, :left]}
                   ]},
                  {:=, [],
                   [
                     {:single_preload, [], nil},
                     {{:., [], [{:__aliases__, [], [:Keyword]}, :get]}, [],
                      [{:opts, [], nil}, :single_preload, true]}
                   ]},
                  {:=, [],
                   [
                     {:base_query, [], nil},
                     {:join, [],
                      [
                        {:query, [], nil},
                        {:join_type, [], nil},
                        [{schema, {:schema_record, [], nil}}],
                        {:assoc, [], [{:schema_record, [], nil}, assoc]},
                        [as: assoc]
                      ]}
                   ]},
                  {:if, [],
                   [
                     {:single_preload, [], nil},
                     [
                       do:
                         {:preload, [],
                          [
                            {:base_query, [], nil},
                            [{assoc, {:record, [], nil}}],
                            [{assoc, {:record, [], nil}}]
                          ]},
                       else: {:preload, [], [{:base_query, [], nil}, [assoc]]}
                     ]
                   ]}
                ]}
           ]
         ]}
      ]
    end
  end

  defp select_timestamp_columns(timestamp_columns) do
    timestamp_columns
    |> Enum.reduce([], fn
      {{:__block__, _key_meta, [:inserted_at]}, {:__block__, _value_meta, [inserted_at_column]}},
      acc ->
        Keyword.put_new(acc, :inserted_at, inserted_at_column)

      {{:__block__, _key_meta, [:inserted_at_source]}, _}, acc ->
        Keyword.put_new(acc, :inserted_at, :inserted_at)

      {{:__block__, _key_meta, [:updated_at]}, {:__block__, _value_meta, [updated_at_column]}},
      acc ->
        Keyword.put_new(acc, :updated_at, updated_at_column)

      {{:__block__, _key_meta, [:updated_at_source]}, _}, acc ->
        Keyword.put_new(acc, :updated_at, :updated_at)

      _, acc ->
        acc
    end)
    |> Keyword.put_new(:inserted_at, :inserted_at)
    |> Keyword.put_new(:updated_at, :updated_at)
    |> Enum.filter(fn {_, falsy_column} -> falsy_column end)
    |> Enum.map(fn {_, column} -> column end)
  end

  defp update_function_kw(function_kw, function_class, new_generated_function) do
    Keyword.update(function_kw, function_class, [new_generated_function], fn existing ->
      [new_generated_function | existing]
    end)
  end

  defp generate_version_function do
    [
      {:@, [],
       [
         {:spec, [],
          [
            {:"::", [],
             [
               {:generated_schema_version, [], []},
               {{:., [],
                 [
                   {:__aliases__, [], [:String]},
                   :t
                 ]}, [], []}
             ]}
          ]}
       ]},
      {:def, [],
       [
         {:generated_schema_version, [], nil},
         [
           {{:__block__, [format: :keyword], [:do]},
               {:@, [],
                [
                  {:schema_gen_tag, [], nil}
                ]}}
         ]
       ]}
    ]
  end

  defp leading_tag(version) do
    [
      {{:., [],
        [
          {:__aliases__, [], [:Module]},
          :register_attribute
        ]}, [],
       [
         {:__MODULE__, [], nil},
         {:__block__, [], [:schema_gen_tag]},
         [
           {{:__block__, [format: :keyword], [:accumulate]}, {:__block__, [], [true]}}
         ]
       ]},
      {:@,
       [
         trailing_comments: [
           %{
             text: "# Anything between the schema_gen_tag module attributes is generated",
             line: nil,
             previous_eol_count: 1,
             column: nil,
             next_eol_count: 1
           },
           %{
             text: "# Any changes between the tags will be discarded on subsequent runs",
             line: nil,
             previous_eol_count: 1,
             column: nil,
             next_eol_count: 2
           }
         ],
         end_of_expression: [newlines: 0]
       ],
       [
         {:schema_gen_tag, [end_of_expression: [newlines: 0]], [{:__block__, [], [version]}]}
       ]}
    ]
  end

  defp trailing_tag(version) do
    {:@, [], [{:schema_gen_tag, [], [{:__block__, [], [version]}]}]}
  end

  defp log(color, command, message, opts) do
    unless opts.quiet do
      Mix.shell().info([color, "* #{command} ", :reset, message])
    end
  end
end