defmodule Mix.Tasks.Ecto.Gen.Schemas do use Mix.Task @requirements ["app.start"] def run(args) do if rem(length(args), 2) != 0 do Mix.raise("Invalid number of arguments") end args = args |> Enum.chunk_every(2) |> Enum.map(fn [name, value] -> {String.trim_leading(name, "-"), value} end) |> Enum.into(%{}) keys = Map.keys(args) if "path" not in keys do Mix.raise("Missing required argument: path") end path = Map.get(args, "path") Mix.Shell.IO.yes?("This will delete and overwrite all files in #{path}. Are you sure?") || Mix.raise("Aborted") File.rm_rf(path) app = Mix.Project.config()[:app] {:ok, modules} = :application.get_key(app, :modules) app_module = app |> Atom.to_string() |> Macro.camelize() schemas = modules |> Enum.filter(fn mod -> String.starts_with?(Atom.to_string(mod), "Elixir." <> app_module) end) |> Enum.filter(fn mod -> is_schema_module?(mod) end) |> Enum.map(fn mod -> get_types_schema(mod) end) schema_associations = schemas |> Enum.map(fn %{module: mod, associations: assocs} -> for assoc <- assocs do {mod, assoc} end end) |> List.flatten() |> Enum.uniq() schema_direct_associations = schema_associations |> Enum.reject(fn {_mod, {_, assoc}} -> is_struct(assoc, Ecto.Association.HasThrough) end) |> Enum.map(fn {mod, {assoc_name, assoc}} -> {{mod, assoc_name}, {assoc.related, assoc.cardinality}} end) |> Map.new() schema_indirect_associations = schema_associations |> Enum.filter(fn {_mod, {_, assoc}} -> is_struct(assoc, Ecto.Association.HasThrough) end) |> Enum.map(fn {mod, {assoc_name, assoc}} -> {related, _} = cycle_through(schema_direct_associations, mod, assoc.through) {{mod, assoc_name}, {related, :many}} end) |> Map.new() all_schema_associations = Map.merge(schema_direct_associations, schema_indirect_associations) |> Enum.map(fn {{mod, assoc_name}, {related, cardinality}} -> {mod, {assoc_name, related, cardinality}} end) |> Enum.group_by(fn {mod, _} -> mod end, fn {_, assoc} -> assoc end) |> Map.new() all_schema_embeds = schemas |> Enum.map(fn %{module: mod, embeds: embeds} -> for {embed_name, embed} <- embeds do {mod, {embed_name, embed}} end end) |> List.flatten() |> Enum.uniq() |> Enum.map(fn {mod, {embed_name, embed}} -> {mod, {embed_name, embed.related, embed.cardinality}} end) |> Enum.group_by(fn {mod, _} -> mod end, fn {_, embed} -> embed end) |> Map.new() schemas |> Enum.map(fn %{module: mod, fields: fields} -> assoc = Map.get(all_schema_associations, mod, %{}) |> Enum.map(fn {name, rel, card} -> {name, {rel, card}} end) |> Map.new() embed = Map.get(all_schema_embeds, mod, %{}) |> Enum.map(fn {name, rel, card} -> {name, {rel, card}} end) |> Map.new() fields = Enum.map(fields, &to_typescript(:field, &1, app_module)) assoc = Enum.map(assoc, &to_typescript(:association, &1, app_module)) embed = Enum.map(embed, &to_typescript(:embed, &1, app_module)) {types, imports} = (fields ++ assoc ++ embed) |> Enum.map(fn %{line: line, import: import} -> {line, import} end) |> Enum.unzip() %{ types: types, imports: imports, filename: mod_to_filename(mod, app_module), type_name: mod_to_type_name(mod), mod: mod } end) |> Enum.group_by(fn %{filename: filename} -> filename end, fn item -> item end) |> Enum.each(fn {filename, items} -> mod = items |> List.first() |> Map.get(:mod) import = items |> Enum.map(fn %{imports: imports} -> imports end) |> List.flatten() |> Enum.uniq() |> Enum.reject(fn import -> import == "" end) |> Enum.reject(fn import -> import |> String.trim_trailing("';") |> String.ends_with?(mod_to_import(mod, app_module)) end) |> Enum.join("\n") types = for %{types: items, type_name: type_name} <- items do """ export type #{type_name} = { #{items |> Enum.join("\n ")} } """ end types = Enum.join(types, "\n\n") content = """ #{import} #{types} """ full_path = Path.join(path, filename) dir = Path.dirname(full_path) File.mkdir_p!(dir) File.write!(full_path, content) end) full_path = Path.join(path, "util.ts") File.write(full_path, """ export type Pagination = { items: T[]; total: number; page: number; size: number; }; """) end def mod_to_filename(mod, app_module) do [_ | path] = mod |> Atom.to_string() |> String.replace("Elixir." <> app_module, "") |> String.split(".") |> Enum.reverse() path = path |> Enum.reverse() |> Enum.map(&Macro.underscore/1) |> Path.join() path <> ".ts" end def mod_to_type_name(mod) do mod |> Atom.to_string() |> String.split(".") |> List.last() |> Macro.camelize() end def mod_to_import(mod, app_module) do "$schemas/#{mod_to_filename(mod, app_module) |> String.replace(".ts", "")}" end def cycle_through(schema_direct_associations, mod, [first | through]) do case through do [] -> Map.get(schema_direct_associations, {mod, first}) [next | rest] -> {next_mod, _} = Map.get(schema_direct_associations, {mod, first}) cycle_through(schema_direct_associations, next_mod, [next | rest]) end end defp get_types_schema(mod) do embeds = mod.__schema__(:embeds) embeds_and_types = for embed <- embeds, into: %{}, do: {embed, mod.__schema__(:embed, embed)} fields = mod.__schema__(:fields) virtual_fields = mod.__schema__(:virtual_fields) fields = fields ++ virtual_fields fields = Enum.filter(fields, fn field -> field not in embeds end) fields_and_types = for field <- fields, into: %{}, do: {field, mod.__schema__(:type, field)} associations = mod.__schema__(:associations) associations_and_types = for assoc <- associations, into: %{} do {assoc, mod.__schema__(:association, assoc)} end %{ module: mod, fields: fields_and_types, associations: associations_and_types, embeds: embeds_and_types } end defp is_schema_module?(module) do Code.ensure_loaded?(module) and function_exported?(module, :__schema__, 1) end def to_typescript(:field, {key, type}, _app_module) do %{line: "#{key}: #{type_to_typescript(type)};", import: ""} end def to_typescript(field_type, {key, {mod, card}}, app_module) when field_type in [:association, :embed] do type_name = mod_to_type_name(mod) ts_type = case card do :one -> type_name <> " | null" :many -> type_name <> "[]" end %{ line: "#{key}: #{ts_type};", import: "import { type #{type_name} } from '#{mod_to_import(mod, app_module)}';" } end def type_to_typescript(:id), do: "number" def type_to_typescript(:integer), do: "number" def type_to_typescript(:float), do: "number" def type_to_typescript(:boolean), do: "boolean" def type_to_typescript(:string), do: "string" def type_to_typescript(:utc_datetime), do: "string" def type_to_typescript(:naive_datetime), do: "string" def type_to_typescript(:binary_id), do: "string" def type_to_typescript(:binary), do: "string" def type_to_typescript(:map), do: "Record" def type_to_typescript({:array, inner_type}), do: type_to_typescript(inner_type) <> "[]" def type_to_typescript({:map, inner_type}), do: "Record" def type_to_typescript({:parameterized, {Ecto.Enum, %{mappings: mappings}}}) do mappings |> Keyword.values() |> Enum.map(fn item -> "'#{item}'" end) |> Enum.join(" | ") end def type_to_typescript(_) do "any" end end