katafrakt

katafrakt

Providing default implementations - a design problem with protocols

Hi, I’m contemplating writing an admin library, similar to Kaffy and LiveAdmin, but filling a few gaps found while using Kaffy. For that I figure I would leverage protocols instead of giant configs (LiveAdmin) or magic admin modules (Kaffy). But after some initial coding I see a few problems.

What I have right now is this:

defimpl Admin.Resource, for: App.Accounts.User do
  def name(_), do: "User"
  def slug(_), do: "user"

  def form(user) do
    import Admin.FormBuilder

    new(user)
    |> title(if user.id, do: "User #{user.id}", else: "New user")
    |> field(:email)
    |> field(:first_name)
    |> field(:last_name)
    |> build()
  end
end

This is nice, but obviously the number of customizable things grows in time. Not only I want to have customizable form, but also records lists, their fields, custom actions, custom mass actions etc. On the other hand, I don’t want to force user to define all functions when they just may use defaults (for example, no need for custom slug or resource name).

So far I have two ideas and I want to know what you think:

1. Have a macro with default

defimpl Admin.Resource, for: App.Accounts.User do
  # I don't want to customize slug and name

  def form(user) do
    # ...
  end

  use Admin.Resource.DefaultImplementations
end

This has a problem with either having the users remember to put it at the end of the module, so default implementation does not shadow custom implemementations - this is not POLS and what is usually done with use. Also, it generates warnings. Alternatively, there would have to be an option to what default implementations to use, for example:

defimpl Admin.Resource, for: App.Accounts.User do
  use Admin.Resource.DefaultImplementations, except: [:form, :custom_actions]
end

Both are not really great in my opinion, I would especially hate generating many warnings for the user (so using import instead is not great too).

2. Use many small protocols

Alternatively, I could have a protocol for just about anything that is customizable and user would choose what to implement. So we will have Admin.Resource, Admin.ResourceTable.Columns, Admin.ResourceTable.Actions, Admin.ResourceTable.MassActions, Admin.ResourceTable.Query, Admin.ResourceTable.SearchQuery, Admin.Resource.FormCustomActions etc. Each one of these will probably will end up with just one function (call?). Seems sensible to some extent, but I feel that having to implement these many protocols might put people off.

Which solution appeals to you more? Or maybe there’s some alternative I missing? Or maybe using protocols for that wasn’t such a great idea…

Most Liked

al2o3cr

al2o3cr

This sounds like the situation defoverridable is intended for.

zachallaun

zachallaun

Every time I’ve personally started off with a protocol for some generic piece of functionality, I’ve eventually switched to a behaviour. I believe they are both more explicit and more flexible, and lend themselves well to the defoverridable strategy that @al2o3cr mentioned.

defmodule AdminResources.User do
  use Admin.Resource, schema: App.Accounts.User
  # defines default impls for all required callbacks

  # override where needed
  @impl true
  def whatever do
    …
  end
end
BartOtten

BartOtten

That was yesterday, today is a new day!

Which made you valuable for the community already. Take credit :wink:

cognivore

cognivore

You have stumbled upon on the many faces of the expression problem!

One doesn’t need to squint hard that the notion of your architecture to be truly scalable under development (what I mean here, strictly, is to guarantee that at no point in time you have added a new data type such as App.Accounts.User, but forgot to implement some method such as form and, conversely, you have added some method such as form and didn’t match some of the data types such as App.Accounts.User), is the same as the notion of adding a new function and a new type case at the same time.

I think what the colleagues who have answered with defoverridable suggestion imply is something like the idea from the post-scriptum of this post, which is “just being able to scale code base along one function / type case axis is enough for scalable code bases that are also ergonomic”.

That said, defoverridable is not a panacea. It allows you to add more types easily, but based on your question you seem to be more concerned about adding more functions. Furthermore, you venture into the dangerous waters of default implementations which, if unconstrainted, can quickly turn unusable. Ask yourself when have you last seen a useful default implementation that survives class inheritance?!

This is why, I would argue that what you actually need a hierarchy of protocols. One could say, you need traits! Now if you’re able to define your default implementaitons in terms of existing traits, these implementations shall trivially survive subtyping!

If you wonder where can you get hierarchical traits in Elixir, type_class library has got you covered!
What’s more, is that you can be principled about trait methods that you define and add properties for the traits that shall be checked at compile time.

Finally, on ergonomics that you desire.

Consider you have:

defclass A do
  def f(x), do: x
end

defclass B do
  def g(_x, y), do: y
end

defclass C do
  extend A
  extend B
  def h(x, _y), do: x
end

The UX you want is

definst C, for: X do
  def f(x), do: x.c
  def h(x, y), do: {x.c, y}
end

Sadly, the best type_class can do is:

definst B, for: X
definst A, for: X do
  def f(x), do: x.value
end
definst C, for: X do
  def h(x, y), do: {x.value, y}
end

I sketched out an implementation plan for shallow dependency instance definitions here.

In general, I would strongly suggest you having the safest architecture that enforces completeness of function implementations over data types over both developer’s user experience and trying to chase the expression problem. Whatever it means for your particular problem.

tfwright

tfwright

@katafrakt there are a couple of suggestions in this thread you might want to check out: Overridable functions in protocol implementations? - #6 by Ajwah

That said, when I created LiveAdmin I did consider integrating directly with the Ecto schema modules themselves to handle config, but I ultimately decided against it because I wanted something as flexible and light-weight as possible. Personally I preferred to keep that noise out of the application business logic, but other users might prefer to have it in the contexts, or separate per-schema modules, or whatever. It’s certainly true that eventually this can lead to unwieldy configs. However, since the config is just a list, there is nothing to stop the user from leveraging whatever language features they want to specify their config implementations consistently for all the schemas, and furthermore, to pass the result via app level config, rather than per-schema configs in the router. If you wanted to use protocols to contain your config logic, you could do something like this:

config :live_admin, 
  label_with: {Admin.Resource, :label, []}, title_with: {Admin.Resource, :title, []}, ...

and then the router would only need the list of schemas, which is also something you could generate dynamically.

Admittedly, not all config is currently so conducive to that approach and would need additional effort to generate, but I think it should be doable if desired. Maybe that is something I should take a look at formalizing a bit more.

Where Next?

Popular in Questions Top

JDanielMartinez
Hi! May someone helps me, please! I have two apps into an umbrella project: the first one is Database, which manages queries, and the se...
New
joaquinalcerro
Hi there, I am working with Ecto-Postgresql and I need to call all of the records from a specific table but the table has 40,000 record...
New
aalberti333
As the title describes, I’m trying to run Enum.map() over a list of key/value pairs, where the value is a map. My data looks like this: ...
New
lastday4you
I wanted to check elixir version in phoenix because i found that my elixir is 1.5 but when i use Enum.chunk_by it said the function is un...
New
stefanchrobot
What’s the safe way to decode a JSON string into a struct? I want to avoid calling String.to_atom. Jason.decode can give me a map with st...
New
freewebwithme
Using vs code and installed ElixirLS: support and debugger. And I got an error popped up on start up says Failed to run ‘elixir’ comma...
New
shahryarjb
Hello, I have map which I want to convert it to string like this: the map: %{last_name: "tavakkoli", name: "shahryar"} the string I ne...
New
johnnyicon
Hi all, I've just started learning Elixir and Phoenix Framework, so please pardon my n00bness at this stage. I'm trying to use Postg...
New
kostonstyle
Hi all I want to have a unix time, from the current time plus 1 hour. DateTime.now + 1 hour How to get it in elixir? Thanks
New
vrod
I am using the Starship cross-shell prompt – it seems pretty nice, but I get some errors: [WARN] - (starship::utils): Executing command ...
New

Other popular topics Top

srinivasu
How to handle excepions in elixir? Suppose i have A, B, C ,D, E modules. and each module has get() function. A.get() method will call th...
New
dotdotdotPaul
Okay, I'm having a heck of a time trying to figure out how to best handle the validation of belongs_to associations in Ecto. I'm sure I'...
New
bsollish-terakeet
Credo is smart enough to check for (something like) this: assert length(the_list) == 0 with this response: Checking if an enum is empt...
New
yawaramin
In the Dialyzer docs ( http://erlang.org/doc/man/dialyzer.html#requesting-or-suppressing-warnings-in-source-files ), there is a way to tu...
New
aalberti333
As the title describes, I’m trying to run Enum.map() over a list of key/value pairs, where the value is a map. My data looks like this: ...
New
vac
Hi, I'm quite new in Elixir and I'm trying to format a string to a PEM format. I have the certificate value like MIIDBTCCAe2...... and ...
New
minhajuddin
I have seen a lot of code which picks the first element from a list using Enum.at(0) instead of List.first. Is there a reason why people ...
New
ashish173
I am using Ecto timestamps with postgres, I can see the timestamps() use the :naive_dateime but for my use case I wanted to store the ti...
New
electic
Hi, I am new to Elixir. I am trying to use the DateTime component to insert a date into MySQL however the there seems to be no way to fo...
New
Fl4m3Ph03n1x
About me? ( if you have nothing better to do than reading about some random guy in the internet :stuck_out_tongue: ) Hello all, this is ...
New

We're in Beta

About us Mission Statement