ash icon indicating copy to clipboard operation
ash copied to clipboard

`mix ash.new`

Open zachdaniel opened this issue 4 years ago • 20 comments

Getting started could be made much easier if we created a mix ash.new similar to mix phx.new, which would have options to easily include extensions, and add phoenix or plug. For example:

mix ash.new --json_api --graphql --policy_authorizer --csv --phoenix

zachdaniel avatar Nov 22 '20 22:11 zachdaniel

I started playing with that and came with an initial sketch, currently all in one file lib/mix/tasks/ash.ex It would be good to get some opinions on that before I continue.

defmodule Mix.Tasks.Ash.Resource do
  use Mix.Task
  alias Mix.Tasks.Helpers

  @impl Mix.Task
  @shortdoc """
  Initializes new resource in lib/myapp/resources/__.ex with default content
  """
  @doc """
  Initializes new resource in lib/myapp/resources/__.ex with default content

  run as mix ash.resource user first_name last_name password email --data_layer postgres
  """
  @entry """
  defmodule <%= project_name %>.<%= module_name %> do
  <%= if data_layer == "postgres" do %>
    use Ash.Resource, data_layer: AshPostgres.DataLayer

    postgres do
      repo <%= project_name %>.Repo
      table "<%= name %>"
    end
  <% end %>
  <%= if data_layer == "ets" do %>
    use Ash.Resource, data_layer: Ash.DataLayer.Ets
  <% end %>

    attributes do
      uuid_primary_key :id
      <%= for attribute <- attributes do %>
        attribute :<%= attribute %>, :string do
          # allow_nil? false
          # constraints max_length: 255
        end
      <% end %>

      # Alternatively, you can use the keyword list syntax
      # You can also set functional defaults, via passing in a zero
      # argument function or an MFA
      attribute :public, :boolean, allow_nil?: false, default: false

      # This is set on create
      create_timestamp :inserted_at
      # This is updated on all updates
      update_timestamp :updated_at

      # `create_timestamp` above is just shorthand for:
      # attribute :inserted_at, :utc_datetime_usec,
      #   writable?: false,
      #   default: &DateTime.utc_now/0
    end
  end
  """
  def run([]), do: IO.puts("Name of resource needs to be specified")

  def run(args) do
    {switches, [resource | attributes], _invalid} =
      OptionParser.parse(args,
        aliases: [v: :verbose, d: :data_layer],
        strict: [debug: :boolean, data_layer: :string]
      )

    data_layer = Keyword.get(switches, :data_layer, "postgres")

    initial_data =
      EEx.eval_string(@entry,
        module_name: Helpers.capitalize(resource),
        data_layer: data_layer,
        project_name: Helpers.project_module_name(),
        name: resource,
        attributes: attributes
      )

    if not File.exists?(Helpers.resources_folder()), do: File.mkdir!(Helpers.resources_folder())
    File.write!(Helpers.resource_file_name(resource), initial_data)
    data_layer_info(data_layer)
    resource_info(resource)
  end

  defp resource_info(resource) do
    IO.puts("""
    First, ensure you've added ash_postgres to your mix.exs file.
    Please add your resource to #{Helpers.api_file_name("api")}
    """)
  end

  defp data_layer_info("postgres") do
    IO.puts("""
    First, ensure you've added ash_postgres to your mix.exs file.

      {:ash_postgres, "~> x.y.z"}

    and run

    mix ash_postgres.generate_migrations
    """)
  end
end

defmodule Mix.Tasks.Ash.New do
  alias Mix.Tasks.Helpers

  @entry_data """
  defmodule <%= project_module_name %>.<%= module_name %> do
    use Ash.Api

    resources do
      # add your resources here
      #resource <%= project_module_name %>.ResourceName
    end
  end
  """

  @shortdoc """
  Initializes new api file lib/api.ex with default content
  """
  @doc """
  Initializes new api file lib/api.ex with default content

  run as mix ash.new
  """
  def run([]), do: run(["api"])

  def run([file_name | _]) do
    initial_data =
      EEx.eval_string(@entry_data,
        module_name: Helpers.capitalize(file_name),
        project_module_name: Helpers.project_module_name()
      )

    File.write!(Helpers.api_file_name(file_name), initial_data)
  end
end

defmodule Mix.Tasks.Helpers do
  def capitalize(""), do: ""

  def capitalize(name) do
    with <<first::utf8, rest::binary>> <- name, do: String.upcase(<<first::utf8>>) <> rest
  end

  def project_module_name() do
    String.split(app_name(), "_") |> Enum.map(&capitalize/1) |> Enum.join()
  end

  def app_name() do
    Mix.Project.config()[:app] |> Atom.to_string()
  end

  def lib_folder(), do: "lib/" <> app_name()
  def api_file_name(main_file), do: lib_folder() <> "/" <> main_file <> ".ex"
  def resources_folder(), do: lib_folder() <> "/resources"
  def resource_file_name(name), do: resources_folder() <> "/" <> name <> ".ex"
end

dkuku avatar Mar 25 '21 19:03 dkuku

This looks great so far! Awesome work! A couple notes:

1.) I think we want them to be able to optionally specify a context, and if they do that, the file would be lib/myapp/context/resources/resource.ex, and the module name would be App.Context.Resource. If they specify a context for the api, that file would be lib/myapp/context/context.ex. In my own projects I've been dropping the convention of calling the file api.ex and the module Api as it is mostly just superfluous.

2.) These tasks are excellent, but they would also fall more under mix ash.gen.resource and mix ash.gen.api. mix ash.new should be an archive that acts as mix phx.new does. However, we definitely need both and this is a great place to start.

Other than those things, this looks awesome and I'd be happy to get this in!

zachdaniel avatar Mar 25 '21 20:03 zachdaniel

This can accept an --context param, this is not a problem. I can do this over the weekend. I need also split it to separate files, and move the templates to separate files too and then I'll push a pr

dkuku avatar Mar 25 '21 20:03 dkuku

Now that I think of it though, lets call the param --api.

zachdaniel avatar Mar 25 '21 21:03 zachdaniel

And when creating an api, it can just have an optional name, such that mix ash.gen.api makes lib/myapp/api.ex and mix ash.gen.api context makes lib/myapp/context/context.ex

zachdaniel avatar Mar 25 '21 21:03 zachdaniel

One more thing:

I think we also want to split the extension specific parts into the actual extension repo. So what we would do is say something like:

AshPostgres.DataLayer.resource_template(...options)

That way if we update ash_postgres we can just update its template there.

zachdaniel avatar Mar 25 '21 21:03 zachdaniel

ok, thanks for the suggestions

dkuku avatar Mar 25 '21 22:03 dkuku

When I think now about it and we can have the postgres specific task in the ash-postgres repo so if its not installed it won't even show up in the list this way we can have mix ash.gen.resource and when ash-postgres is installed we would have additional mix ash.gen.resource.postgres. The helper functions would be in the main repo so not much duplication really and you need the main repo anyway. Also I'm thinking of having 2 different template files for it - one heavily commented and with examples and other one clean. If you're a beginner then its nice to have examples in the same file. If you know what your'e doing then deleting every time the examples when you generate 10 resources might be a pain.

dkuku avatar Mar 26 '21 17:03 dkuku

I like the idea of having a specialized template, but it might be better to have it in the same template, something like --with-guides, that way we don't have to maintain two separate templates. I'm not 100% sure on that though, so I'm open to two separate templates.

However, I don't think we want separate tasks for ash_postgres and ash, because eventually someone will want to generate their resources with ash_json_api and ash_graphql and friends, and they would have to pick only one if we went that route. Ideally we'd see something like:

mix ash.gen.resource --json_api --graphql --postgres

And they'd get a resource with DSL for all of those extensions.

zachdaniel avatar Mar 26 '21 17:03 zachdaniel

ok, no problem. I need to check how this is all done in phoenix.

dkuku avatar Mar 26 '21 17:03 dkuku

Great work so far 🎉 no rush

zachdaniel avatar Mar 26 '21 17:03 zachdaniel

This is more or less usable. Feels a bit pythonic, that's my background the largest file is generated with this command: mix ash.gen.resource users --json-api --graphql --postgres --policy-authorizer name age integer born date --api accounts --phoenix --csv on the other hand a minimal one is generated with: mix ash.gen.resource users name age integer born date --no-timestamps --no-guides Most of the logic for resource is contained in the /priv/resources.ex.eex - the content of this file needs to be edited by someone who knows more about this project - it's my first contact with it really, and I just pasted what I found in the docs.

If someone wants to pick up from this point feel free to pick it up

dkuku avatar Mar 27 '21 15:03 dkuku

I think it looks great! Just one important thing that remains on it, which I won't have time to address but anyone else can feel free to do the work: we should really split up the responsibility of the contents of the extensions specific sections. Ideally, those would live in each of the various extensions, so we don't have to release Ash when the extension changes.

zachdaniel avatar Mar 31 '21 20:03 zachdaniel

@zachdaniel the template per package needs to be a separate file:

defmodule AshPostgres.Templates do
  def resource_template(assigns) do
    """
    postgres do
      repo #{assigns.project_name}.Repo
      table "#{assigns.table_name}"
    end
    """
  end
  def guide_template(assigns) do
    """
    # nothing in here - just for test
    # postgres do
    #   repo #{assigns.project_name}.Repo
    #   table "#{assigns.table_name}"
    # end
    """
  end
end

This way I can programmatically check if it exists and if yes use it. I think we need for now have copies in ash until all the packages wont have own templates, then it can be removed Currently I use try/rescue for this and when it fails the check the local templates file I'm open to ideas

dkuku avatar Apr 01 '21 22:04 dkuku

Alright, I can get behind having simple defaults for each section in core :) Let me review and we can see about getting it merged! As you mentioned, I'll probably make some updates to the templates here and there, but the structure so far looks excellent.

zachdaniel avatar Apr 02 '21 16:04 zachdaniel

Ok, I'm thinking to make Ash.Template behaviour so its standarized.

dkuku avatar Apr 02 '21 17:04 dkuku

Great idea 👍🏻

zachdaniel avatar Apr 02 '21 17:04 zachdaniel

@dkuku could I suggest that instead of an --api flag (since I think it's required) we treat it the same as a Context in phoenix i.e.:

# instead of
mix ash.gen.resource users ... --api accounts
# we do 
mix ash.gen.resource accounts users ...

This will fit the mental model of most phoenix developers. We may want to keep the start completely the same, i.e.:

# CamelCaseContext CamelCaseResourceName table_name(snake_case)
mix ash.gen.resource Accounts User users ...

Especially since you'll have a template for that in phoenix, and also we do need all of those things (if you select Postgres as the type at least)

jonathanstiansen avatar Apr 03 '21 16:04 jonathanstiansen

image

image

tlietz avatar Mar 28 '22 01:03 tlietz

Some additional considerations here:

  1. there are different concepts of where to put the file depending on what you are doing

  2. some things are sort of "contained" by others, and perhaps our mix ash.gen command should take that into account. For example, we could support adding a resource to the registry of an API automatically when you generate it. I'm not sure what that would ultimately look like.

  3. I'm going to have the latest reccomendations for how to organize your project as part of the new guides. For example, its no longer recommended that you'd call each thing an Api, e.g MyApp.Stuff.Api and MyApp.Stuff.Resource. Instead, you'd call the api module, MyApp.Stuff (w/o the Api at the end)

zachdaniel avatar Mar 28 '22 04:03 zachdaniel

Closing this in favor of a set of features that will be introduced with the "generators" feature.

zachdaniel avatar Sep 10 '22 03:09 zachdaniel