defmodule Mix.Tasks.Potionx.Gen.GqlForModel do alias __MODULE__ @shortdoc "Generates GraphQL mutations, queries and types for an Ecto model" @task_name "potion.gen.gql_for_model" @default_opts [schema: true, context: true] @switches [binary_id: :boolean, table: :string, web: :string, schema: :boolean, context: :boolean, context_app: :string, no_associations: :boolean] defstruct app_otp: nil, context_name: nil, context_name_snakecase: nil, module_name_data: nil, module_name_graphql: nil, dir_context: nil, dir_graphql: nil, dir_test: nil, graphql_fields: nil, lines: %{}, potion_name: "Potionx", mock: nil, mock_patch: nil, model: nil, model_name_atom: nil, model_file_path: nil, model_name: nil, model_name_graphql_case: nil, model_name_snakecase: nil, no_associations: false, no_frontend: false, no_mutations: false, no_queries: false, validations: [] use Mix.Task alias Mix.Phoenix.{Context, Schema} alias Potionx.DocUtils def add_lines_to_block(block, lines_to_add, start_line, indent_size) when is_binary(start_line) do start_index = Enum.find_index(block, fn l -> String.starts_with?(l, start_line) end) add_lines_to_block(block, lines_to_add, start_index, indent_size) end def add_lines_to_block(block, lines_to_add, start_index, indent_size) do end_index = Enum.slice(block, start_index..-1) |> Enum.find_index(fn l -> String.starts_with?(l, DocUtils.indent_to_string(indent_size) <> "end") end) end_index = end_index + start_index Enum.concat( [ Enum.slice(block, 0..(end_index - 1)), lines_to_add, Enum.slice(block, end_index..-1) ] ) end def add_to_frontend_routes(%GqlForModel{no_frontend: true} = state), do: state def add_to_frontend_routes(%GqlForModel{} = state) do index = [ "frontend", "admin", "src", "routes", "index.ts" ] |> Enum.join("/") route_names = [ "frontend", "admin", "src", "routes", "routeNames.ts" ] |> Enum.join("/") admin_nav = [ "frontend", "admin", "src", "useAdminHeaderNav.ts" ] |> Enum.join("/") File.write!( index, Enum.join( [ "import Route#{state.model_name}Edit from './Route#{state.model_name}Edit/Route#{state.model_name}Edit'", "import Route#{state.model_name}List from './Route#{state.model_name}List/Route#{state.model_name}List'", File.read!(index), """ routes.push( { name: routeNames.#{state.model_name_graphql_case}Edit, path: '/#{String.replace(state.model_name_snakecase, "_", "-")}-list/:id', component: Route#{state.model_name}Edit } ) """, """ routes.push( { name: routeNames.#{state.model_name_graphql_case}List, path: '/#{String.replace(state.model_name_snakecase, "_", "-")}-list', component: Route#{state.model_name}List } ) """ ], "\r\n" ) ) File.write!( route_names, File.read!(route_names) |> String.replace( "export enum routeNames {", Enum.join( [ "export enum routeNames {", " #{state.model_name_graphql_case}Edit = \"#{state.model_name}Edit\",", " #{state.model_name_graphql_case}List = \"#{state.model_name}List\"," ], "\r\n" ) ) ) File.write!( admin_nav, Enum.join( [ File.read!(admin_nav), """ nav.push( { label: "#{state.model_name}s", to: { name: routeNames.#{state.model_name_graphql_case}List } } ) """, ], "\r\n" ) ) state end @doc false def build(args) do {opts, parsed, _} = parse_opts(args) {opts, validate_args!(parsed)} end def create_frontend_routes(%GqlForModel{no_frontend: true} = state), do: state def create_frontend_routes(%GqlForModel{} = state) do ["Edit", "List"] |> Enum.each(fn k -> template = "priv/templates/#{@task_name}/Route#{k}.tsx" target = [ "frontend", "admin", "src", "routes", "Route#{state.model_name}#{k}", "Route#{state.model_name}#{k}.tsx" ] |> Enum.join("/") File.mkdir_p!(Path.dirname(target)) EEx.eval_string( Application.app_dir( :potionx, template ) |> File.read!, Enum.map( Map.from_struct(state), &(&1) ) ) |> (fn res -> File.write!( target, res ) end).() end) state end def ensure_files_and_directories_exist(%GqlForModel{} = state) do if (not File.dir?(state.dir_context)) do Mix.raise """ Context directory #{state.dir_context} is missing """ end files_to_be_generated(state) |> Enum.map(fn {k, path_enum} -> File.mkdir_p!(Path.join(Enum.slice(path_enum, 0..-2))) path = Path.join(path_enum) file_name = cond do String.ends_with?(to_string(k), "_test") -> to_string(k) <> ".exs" String.ends_with?(to_string(k), "_json") -> String.replace_trailing(to_string(k), "_json", "") <> ".json" true -> to_string(k) <> ".ex" end # if does not exist, create using templates if not File.exists?(path) do EEx.eval_string( Application.app_dir( :potionx, "priv/templates/#{@task_name}/#{file_name}" ) |> File.read!, Enum.map(Map.from_struct(state), &(&1)) ) |> (fn res -> File.write!(path, res) end).() end end) state end @doc false def files_to_be_generated(%GqlForModel{} = state) do %{ app_schema: [state.dir_graphql, "schema.ex"], model_mock: [state.dir_context, "#{state.model_name_snakecase}_mock.ex"], model_mock_json: [ "frontend", "shared", "src", "models", state.context_name, state.model_name, "#{state.model_name_graphql_case}.mock.json" ], model_json: [ "frontend", "shared", "src", "models", state.context_name, state.model_name, "#{state.model_name_graphql_case}.json" ], mutations: [state.dir_graphql, "schemas", state.model_name_snakecase, "#{state.model_name_snakecase}_mutations.ex"], mutations_test: [state.dir_test, "mutations", "#{state.model_name_snakecase}_mutations_test.exs"], queries: [state.dir_graphql, "schemas", state.model_name_snakecase, "#{state.model_name_snakecase}_queries.ex"], queries_test: [state.dir_test, "queries", "#{state.model_name_snakecase}_queries_test.exs"], resolver: [state.dir_graphql, "resolvers", "#{state.model_name_snakecase}_resolver.ex"], service: [state.dir_context, "#{state.model_name_snakecase}_service.ex"], types: [state.dir_graphql, "schemas", state.model_name_snakecase, "#{state.model_name_snakecase}_types.ex"], } |> (fn res -> [{:no_mutations, "mutations"}, {:no_queries, "queries"}] |> Enum.reduce(res, fn {k, v}, acc -> if (Map.get(state, k)) do Map.drop(acc, [String.to_atom(v), String.to_atom(v <> "_test")]) else acc end end) end).() end def keyword_list_to_map(list) do if (Keyword.keyword?(list)) do Enum.into(list, %{}) else list end end def load_lines(%GqlForModel{} = state) do files_to_be_generated(state) |> Enum.filter(fn {k, _} -> Enum.member?([:app_schema, :mutations, :types, :queries], k) end) |> Enum.reduce(state, fn {k, path_enum}, state -> lines = File.read!( Path.join(path_enum) ) |> String.trim |> String.split(~r{(\r?)\n}) %{ state | lines: Map.put(state.lines, k, lines) } end) end def load_model(%GqlForModel{} = state) do %{ state | model: [ "Elixir", state.module_name_data, state.context_name, state.model_name ] |> Enum.join(".") |> String.to_atom } end def load_validations(%GqlForModel{} = state) do %{ state | validations: validations( state.model.changeset(struct(state.model, %{}), %{}) ) } end def maybe_add_default_types(%GqlForModel{} = state) do [ { "input_object :#{state.model_name_atom}_filters_single do", [ "field :id, non_null(:id)" ] }, { "object :#{state.model_name_snakecase}_mutation_result do", [ "field :errors, list_of(:string)", "field :errors_fields, list_of(:error)", "field :node, :#{state.model_name_snakecase}", "field :success_msg, :string" ] } # { # "object #{state.model_name_snakecase}_collection_result do", # [ # ":errors, list_of(:string)", # ":nodes, list_of(#{state.model_name_snakecase})", # ":query_info, :query_info" # ] # }, ] |> Enum.reduce(state, fn {head, lines}, acc -> if Enum.find(acc.lines.types, fn l -> String.contains?(l, head) end) do acc else block_to_add = Enum.concat([ [DocUtils.indent_to_string(2) <> head], Enum.map(lines, fn l -> DocUtils.indent_to_string(4) <> l end), [DocUtils.indent_to_string(2) <> "end"] ]) %{ acc | lines: Map.put(acc.lines, :types, add_lines_to_block(acc.lines.types, block_to_add, Enum.at(acc.lines.types, 0), 0) ) } end end) end def maybe_add_line(block, line, indent_size, close \\ false) do lines_to_add = block |> Enum.find_index(fn l -> String.contains?(l, line) end) |> case do nil -> [DocUtils.indent_to_string(indent_size) <> line] |> (fn res -> if close do Enum.concat([res, [DocUtils.indent_to_string(indent_size) <> "end"]]) else res end end).() _ -> [] end add_lines_to_block(block, lines_to_add, Enum.at(block, 0), indent_size - 2) end def maybe_add_node_interface_type_resolve(%GqlForModel{} = state) do # look for node interface, next line is resolve type # insert into resolve_type block interface_block_start_index = Enum.find_index(state.lines.app_schema, fn l -> String.starts_with?( l, DocUtils.indent_to_string(2) <> "node interface" ) end) Enum.find(state.lines.app_schema, fn l -> String.contains?( l, "#{state.module_name_data}.#{state.context_name}.#{state.model_name}{}, _ ->" ) end) |> if do state else %{ state | lines: Map.put( state.lines, :app_schema, Enum.concat( [ Enum.slice(state.lines.app_schema, 0..interface_block_start_index+1), [ DocUtils.indent_to_string(6) <> "%#{state.module_name_data}.#{state.context_name}.#{state.model_name}{}, _ ->", DocUtils.indent_to_string(8) <> ":#{state.model_name_atom}" ], Enum.slice(state.lines.app_schema, (interface_block_start_index+2)..-1) ] ) ) } end end def maybe_convert_type(type) do case type do :utc_datetime -> :datetime _ -> type end end def maybe_init_types(%GqlForModel{} = state) do Enum.map(types(state), fn line -> {:types, line} end) |> Enum.reduce(state, fn {k, v}, acc -> %{ acc | lines: Map.put( acc.lines, k, maybe_add_line(Map.get(acc.lines, k), v, 2, true) |> maybe_add_line( "connection node_type: :#{state.model_name_atom}", 2, false ) ) } end) end def maybe_update_main_schema(%GqlForModel{} = state) do [ {:no_mutations, "import_types #{state.module_name_graphql}.Schema.#{state.model_name}Mutations"}, {:no_queries, "import_types #{state.module_name_graphql}.Schema.#{state.model_name}Queries"}, {:no_types, "import_types #{state.module_name_graphql}.Schema.#{state.model_name}Types"} ] |> Enum.reduce(state.lines.app_schema, fn {k, line}, acc -> if (Map.get(state, k)) do acc else maybe_add_line( acc, line, 2 ) end end) |> (fn lines -> [ {:no_mutations, "mutation do", "import_fields :#{state.model_name_snakecase}_mutations"}, {:no_queries, "query do", "import_fields :#{state.model_name_snakecase}_queries"}, { :no_mutations, "def dataloader", "|> Dataloader.add_source(#{state.module_name_graphql}.Resolver.#{state.model_name}, #{state.module_name_graphql}.Resolver.#{state.model_name}.data())" } ] |> Enum.reduce(lines, fn {flag, k, v}, acc -> ( Map.get(state, flag) or Enum.find(acc, fn line -> String.contains?(line, v) end) ) |> if do acc else add_lines_to_block( acc, [DocUtils.indent_to_string(4) <> v], DocUtils.indent_to_string(2) <> k, 2 ) end end) end).() |> (fn lines -> %{state | lines: Map.put(state.lines, :app_schema, lines)} end).() end defp parse_opts(args) do {opts, parsed, invalid} = OptionParser.parse(args, switches: @switches) merged_opts = @default_opts |> Keyword.merge(opts) |> put_context_app(opts[:context_app]) {merged_opts, parsed, invalid} end def prepare_mock(params, type \\ :create) do params |> Enum.filter(fn {_, {:assoc, _}} -> false _ -> true end) |> Mix.Phoenix.Schema.params(type) |> Enum.map(fn {:email, _} -> {:email, type === :create && "test@example.com" || "test@example.com"} {k, v} -> {k, v} end) |> Enum.into(%{}) end def pretty_print(m) do inspect(m, pretty: true) |> String.split(~r{(\r?)\n}) |> Enum.map(fn l -> " " <> l end) |> Enum.join("\r\n") end defp put_context_app(opts, nil), do: opts defp put_context_app(opts, string) do Keyword.put(opts, :context_app, String.to_atom(string)) end @doc false @spec raise_with_help(String.t) :: no_return() def raise_with_help(msg) do Mix.raise """ #{msg} mix #{@task_name} expects a context module name, followed by the singular module name mix #{@task_name} Accounts User The context serves as the API boundary for the given resource. Multiple resources may belong to a context and a resource may be split over distinct contexts (such as Accounts.User and Payments.User). """ end @doc false def run(args) do if Mix.Project.umbrella? do Mix.raise "mix #{@task_name} can only be run inside an application directory" end Mix.Task.run("app.start") {opts, [context, schema]} = build(args) this_app = Mix.Phoenix.otp_app() dir_context = Path.join(["lib", "#{this_app}", Macro.underscore(context)]) %GqlForModel{ app_otp: this_app, context_name: context, context_name_snakecase: Macro.underscore(context), dir_context: dir_context, dir_graphql: Path.join(["lib", "#{this_app}_graphql"]), dir_test: Path.join(["test", "#{this_app}_graphql"]), model_file_path: Path.join([dir_context, Macro.underscore(schema) <> ".ex"]), model_name: schema, model_name_atom: Macro.underscore(schema) |> String.to_atom, model_name_graphql_case: Macro.underscore(schema) |> Absinthe.Adapter.LanguageConventions.to_external_name(nil), model_name_snakecase: Macro.underscore(schema), module_name_data: Mix.Phoenix.context_base( Mix.Phoenix.context_app() ), module_name_graphql: Mix.Phoenix.context_base( Mix.Phoenix.context_app() ) <> "GraphQl", no_associations: Keyword.get(opts, :no_associations, false), no_frontend: Keyword.get(opts, :no_frontend, false), no_mutations: Keyword.get(opts, :no_mutations, false), no_queries: Keyword.get(opts, :no_queries, false) } |> ensure_files_and_directories_exist |> load_model |> load_lines |> load_validations |> maybe_init_types |> sync_mocks |> sync_objects |> sync_graphql_files |> maybe_add_default_types |> maybe_add_node_interface_type_resolve |> maybe_update_main_schema |> sync_json_schema |> create_frontend_routes |> add_to_frontend_routes |> write_lines_to_files end def sync_graphql_files(%GqlForModel{} = state) do fields = prepare_mock( state.model.__changeset__ ) state = %{ state | graphql_fields: fields |> Enum.reduce([], fn {_, {:assoc, _}}, acc -> acc {k, _}, acc -> acc ++ [Absinthe.Adapter.LanguageConventions.to_external_name(to_string(k), nil)] end) } [ { :no_queries, "priv/templates/#{@task_name}/collection.gql.ts", [ "frontend", "shared", "src", "models", state.context_name, state.model_name, "#{state.model_name_graphql_case}Collection.gql.ts" ] }, { :no_queries, "priv/templates/#{@task_name}/collection.gql", [ "frontend", "shared", "src", "models", state.context_name, state.model_name, "#{state.model_name_graphql_case}Collection.gql" ] }, { :no_mutations, "priv/templates/#{@task_name}/delete.gql.ts", [ "frontend", "shared", "src", "models", state.context_name, state.model_name, "#{state.model_name_graphql_case}Delete.gql.ts" ] }, { :no_mutations, "priv/templates/#{@task_name}/delete.gql", [ "frontend", "shared", "src", "models", state.context_name, state.model_name, "#{state.model_name_graphql_case}Delete.gql" ] }, { :no_mutations, "priv/templates/#{@task_name}/mutation.gql.ts", [ "frontend", "shared", "src", "models", state.context_name, state.model_name, "#{state.model_name_graphql_case}Mutation.gql.ts" ] }, { :no_mutations, "priv/templates/#{@task_name}/mutation.gql", [ "frontend", "shared", "src", "models", state.context_name, state.model_name, "#{state.model_name_graphql_case}Mutation.gql" ] }, { :no_queries, "priv/templates/#{@task_name}/single.gql.ts", [ "frontend", "shared", "src", "models", state.context_name, state.model_name, "#{state.model_name_graphql_case}Single.gql.ts" ] }, { :no_queries, "priv/templates/#{@task_name}/single.gql", [ "frontend", "shared", "src", "models", state.context_name, state.model_name, "#{state.model_name_graphql_case}Single.gql" ] } ] |> Enum.each( fn {flag, template, path_parts} -> unless Map.get(state, flag) do EEx.eval_string( Application.app_dir( :potionx, template ) |> File.read!, Enum.map( Map.from_struct(state), &(&1) ) ) |> (fn res -> File.write!( Path.join(path_parts), res ) end).() end end ) state end def sync_json_schema(%GqlForModel{} = state) do model_json_raw = File.read!( files_to_be_generated(state).model_json |> Path.join ) model_json = Jason.decode!(model_json_raw, keys: :atoms) state.model.__changeset__ |> Enum.reduce(model_json, fn {_, {:assoc, _}}, acc -> acc {k, {:array, opts}}, acc -> options = case opts do {_, _, %{values: values}} -> values _ -> [] end acc ++ [%{ name: to_string(k), options: options, type: "checkbox", validations: Enum.reduce(state.validations, [], fn {key, v}, acc -> if (key === k) do acc ++ [v] else acc end end) }] {k, v}, acc -> cond do Enum.member?([:inserted_at, :updated_at], k) -> acc true -> acc ++ [%{ name: to_string(k), type: v, validations: Enum.reduce(state.validations, [], fn {key, v}, acc -> if (key === k) do acc ++ [v] else acc end end) }] end end) |> Enum.uniq_by(fn v -> v.name end) |> Jason.encode!(pretty: true) |> (fn res -> File.write!( files_to_be_generated(state).model_json |> Path.join, res ) end).() state end def sync_mocks(%GqlForModel{} = state) do fields = prepare_mock( state.model.__changeset__ ) fields_patch = prepare_mock( state.model.__changeset__, :update ) EEx.eval_string( Application.app_dir( :potionx, "priv/templates/#{@task_name}/model_mock.ex" ) |> File.read!, Enum.map( Map.from_struct(state) |> Map.put(:mock, pretty_print(fields)) |> Map.put(:mock_patch, pretty_print(fields_patch)), &(&1) ) ) |> (fn res -> File.write!( Path.join(files_to_be_generated(state).model_mock), res ) end).() EEx.eval_string( Application.app_dir( :potionx, "priv/templates/#{@task_name}/model_mock.json" ) |> File.read!, Enum.map( Map.from_struct(state) |> Map.put(:mock, Jason.encode!(fields, pretty: true)), &(&1) ) ) |> (fn res -> File.write!( Path.join(files_to_be_generated(state).model_mock_json), res ) end).() state end def sync_objects(%GqlForModel{} = state) do fields = state.model.__changeset__ types(state) |> Enum.reduce(state, fn line, state -> lines = state.lines.types head_index = lines |> Enum.find_index(fn l -> String.contains?(l, line) end) tail_index = lines |> Enum.slice(head_index..-1) |> Enum.find_index(fn l -> String.contains?(l, DocUtils.indent_to_string(2) <> "end") end) |> Kernel.+(head_index) snippet = Enum.slice(lines, head_index..tail_index) Enum.reduce(fields, snippet, fn {k, {:array, _}}, acc -> acc ++ ["field :#{k}, list_of(:string)"] {k, {:assoc, ass}}, acc -> if String.starts_with?(line, "input_object") or state.no_associations do acc else model = ass.related |> to_string() |> String.split(".") |> Enum.at(-1) |> Macro.underscore() result_type = ass.cardinality === :many && "list_of(:#{model})" || model acc ++ ["field :#{to_string(k)}, #{result_type}, resolve: dataloader(#{state.module_name_graphql}.Resolver.#{state.model_name})"] end {:id, _}, acc -> acc {k, v}, acc -> acc ++ ["field :#{k}, :#{to_string(maybe_convert_type(v))}"] end) |> (fn lines -> [:description, :title] |> Enum.reduce(lines, fn key, lines -> if (function_exported?(state.model, key, 1) && not String.starts_with?(line, "input_object")) do lines ++ [ "field :#{to_string(key)}, :string, resolve: Potionx.Resolvers.resolve_computed(#{to_string(state.module_name_data)}.#{to_string(state.context_name)}.#{to_string(state.model_name)}, :#{to_string(key)})" ] else lines end end) end).() |> Enum.reduce(snippet, fn line, acc -> maybe_add_line(acc, line, 4) end) |> (fn snippet -> head = Enum.slice(lines, 0..(head_index - 1)) tail = Enum.slice(lines, (tail_index + 1)..-1) %{ state | lines: Map.put(state.lines, :types, Enum.concat([head, snippet, tail])) } end).() end) end def types(%GqlForModel{} = state) do [ "node object :#{state.model_name_atom} do", "input_object :#{state.model_name_atom}_filters do", "input_object :#{state.model_name_atom}_input do" ] end defp validate_args!([context, schema] = args) do cond do not Context.valid?(context) -> raise_with_help "Expected the context, #{inspect context}, to be a valid module name" not Schema.valid?(schema) -> raise_with_help "Expected the schema, #{inspect schema}, to be a valid module name" context == schema -> raise_with_help "The context and schema should have different names" true -> args end end defp validate_args!(_args) do raise_with_help "Invalid arguments" end def validations(%Ecto.Changeset{} = cs) do cs |> Map.get(:required) |> Enum.map(fn c -> {c, :required} end) |> Kernel.++( cs |> Ecto.Changeset.validations() ) |> Enum.map(fn {k, :required = n} -> {k, %{ name: to_string(n), }} {k, {:format = n, r}} -> {k, %{ name: to_string(n), params: %{ pattern: Regex.source(r) } }} {k, {n, params}} when n == :length or n == :number -> {k, %{ name: to_string(n), params: keyword_list_to_map(params) }} {k, {n, values}} when n == :inclusion or n == :exclusion or n === :subset -> {k, %{ name: to_string(n), params: %{ values: keyword_list_to_map(values) } }} {k, {n, _}} -> {k, %{name: n}} end) end def write_lines_to_files(%GqlForModel{} = state) do files = files_to_be_generated(state) Enum.each(state.lines, fn {k, lines} -> File.write( Path.join(Map.get(files, k)), Enum.join(lines, "\r\n"), [:write] ) end) state end end