Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Questions on approach taken #8

Open
RobStallion opened this issue Oct 9, 2018 · 8 comments
Open

Questions on approach taken #8

RobStallion opened this issue Oct 9, 2018 · 8 comments
Assignees
Labels
help wanted Extra attention is needed question Further information is requested technical

Comments

@RobStallion
Copy link
Member

  • Why is everything done using macros? We could achieve the same result by making them regular functions which takes the relevant module as an argument. Is there any benefits of one approach vs the other?

  • Is the insert function just allowing the user to skip the step where they call the relevant changeset function? Is this a good idea?

    • When inserting into the database in this way will a user only ever need on changeset function? In a regular Phoenix application there may be multiple changesets depending on what needs to be inserted/updated in the database. If a user needs to change their password for example there could be a changeset that deals specifically with just this. (Just to be clear, I cannot actually think of a situation with this approach where a user might need more than one changeset function but thought it best to raise the point in case I have missed something)
  • The get and get_by functions seem to just call their Repo
    equivalents. Are they needed?

@nelsonic nelsonic added help wanted Extra attention is needed question Further information is requested labels Oct 9, 2018
@nelsonic
Copy link
Member

nelsonic commented Oct 9, 2018

@RobStallion great questions! (as always!)
All worth having answers to in the README.md, thanks! ✨

@nelsonic
Copy link
Member

@RobStallion do you feel that your questions have been answered satisfactorily? 💭
if not, please nudge @Danwhy to give a more detailed answer. 😉

@RobStallion
Copy link
Member Author

@nelsonic @Danwhy Sorry if I am being dense but I haven't actually seen the answers yet. I did look at the module and saw that the get function has been updated but that is the only change I have noticed.

Please let me know if I am missing something.

@nelsonic
Copy link
Member

@RobStallion you are not being "dense";
This is an excellent question and one which many other people will have!
@Danwhy / @samhstn are best placed to explain the differences/advantages
of functions vs. macros in this use-case.

@samhstn
Copy link
Member

samhstn commented Oct 19, 2018

Macros vs Regular functions

Say we have our AppendOnly module with the following functions

defmodule AppendOnly do
  def insert_logic(changeset) do
    ...
  def update_logic(changeset) do
    ...
  def one_logic(struct) do
    ...
  def all_logic(struct) do
    ...
end

And say we have the following modules with insert, update, one, all functions behaving the same way

defmodule Address do
  def insert(struct), do: struct |> Address.changeset() |> AppendOnly.insert_logic() |> Repo.insert()
  def update(struct), do: struct |> Address.changeset() |> AppendOnly.update_logic() |> Repo.insert()
  def one, do: Address |> AppendOnly.one_logic() |> Repo.one()
  def all, do: Address |> AppendOnly.all_logic() |> Repo.all()
end
defmodule Customer do
  def insert(struct), do: struct |> Customer.changeset() |> AppendOnly.insert_logic() |> Repo.insert()
  def update(struct), do: struct |> Customer.changeset() |> AppendOnly.update_logic() |> Repo.insert()
  def one, do: Customer |> AppendOnly.one_logic() |> Repo.one()
  def all, do: Customer |> AppendOnly.all_logic() |> Repo.all()
end

Where, for example, the Address.insert function would be used in the following way:

Address.insert(%Address{address: "1600 Pennsylvania Avenue NW", state: "Washington"})

Obviously this is WET code and needs to be cleaned up.

When refactoring here, the only thing that needs to remain the same is that the Address and Customer insert, update, one and all functions keep their behaviour

Regular functions refactor approach

As you say @RobStallion, one approach is to pass in the module into the AppendOnly insert, update, one, all functions.

(after a refactor of AppendOnly to expose insert, update, ...) this would look something like:

defmodule Address do
  def insert(struct), do: AppendOnly.insert(Address, struct)
  def update(struct), do: AppendOnly.update(Address, struct)
  def one, do: AppendOnly.one(Address)
  def all, do: AppendOnly.all(Address)
end

Change 1

Lets say that we now wanted to add a delete function to all modules following this pattern, we would add

...
def delete(struct),
  do: AppendOnly.delete(<Module>, struct)

to every module you wish to add it to.

and add a delete function to the AppendOnly module.

Change 2

Lets say that we now want to change the behaviour of the insert function but only in the Address module

defmodule Address do
  def insert(struct), do: struct |> something_else() |> Repo.insert
  def update(struct), do: AppendOnly.update(Address, struct)
  def one, do: AppendOnly.one(Address)
  def all, do: AppendOnly.all(Address)
end

Macro refactor approach

Another approach is to construct the AppendOnly module as a macro exposing insert, update, one and all as overridable functions

Once this is configured we could set up the Address module like so:

defmodule Address do
  use AppendOnly
end

One really nice thing about macros is that it can reference the module where it is being used and hence, no need to pass the module name anywhere

We repeat as little code as possible in each module following our AppendOnly pattern (making this approach super DRY)

Change 1

We would now like to add a delete function to all modules following this pattern (these would be those with use AppendOnly)

We would simply add a delete function to our AppendOnly module, this would automatically propagate through to Address and Customer

Change 2

We would now like to override the insert function but only in the Address module.

We would simply add our new insert function to the Address module

defmodule Address do
  use AppendOnly

  def insert(struct), do: struct |> something_else() |> Repo.insert
end

Comparing the two approaches

The initial code setup for a module following the append only pattern is a lot cleaner using macros.
One downside of using macros is that it is not very declarative and someone could be confused as to where the insert function is coming from.
In the regular functions approach, it is super clear where insert is defined for instance.

Change 1

Adding a delete function using the macro approach is super clean, you only need to update the AppendOnly module and no other file changes are needed, you aren't going to forget to add it anywhere because the changes propagate automatically.
When you add a delete function using the regular functions approach you must update every module which follows the append only pattern to include it.
When testing in the regular function approach, if you would like to "cover" this new delete function everywhere you would also need to add a test for each delete function in every module, whereas in the macro approach we should only need add one test for the one place delete has been added.

Change 2

The difference here between the two approaches isn't much, there is the same amount of code added, but one difference could be that the macro approach is far less cluttered by other functions which we don't want to override.

In short, I think that the use pattern was intended specifically for this type of abstraction where you have a common set of functions used in different modules.

@mischa-s
Copy link

mischa-s commented Jan 16, 2019

Hello, complete elixir n00b chiming in. Thanks so much for this and all the other great repos on Elixir that you have created.

I tripped over

def update(%__MODULE__{} = item, attrs) do
  item
  |> Map.put(:id, nil)
  |> Map.put(:inserted_at, nil)
  |> Map.put(:updated_at, nil)
  |> __MODULE__.changeset(attrs)
  |> Repo.insert()
end

with the typo

def update(%__MODULE__{} = item, attrs) do
...
  |> Map.put(:insterted_at, nil)
...
end

Finding this typo was very hard because the |>Map.put syntax doesn't raise any compile errors. I went over this with a friend who suggested the following

      def update(%__MODULE__{} = item, attrs) do
        item
        |> struct!(
          id: nil,
          inserted_at: nil,
          updated_at: nil
        )
        |> __MODULE__.changeset(attrs)
        |> Repo.insert()
      end

This did produce much more sensible error messages because it doesn't allow the addition of new properties, but I'm curious if there are reasons that doing each attribute separately in a map.puts makes more sense

@samhstn
Copy link
Member

samhstn commented Jan 16, 2019

@mischa-s nice typo catch 👍 I think you may be on to a potential code smell, nice work

Another way of updating a key and throw a an error if the key doesn't exist is to use the built in update syntax over using the Map.put function.

So,

item
  |> Map.put(:id, nil)
  |> Map.put(:insterted, nil)
  |> Map.put(:updated_at, nil)
  |> __MODULE__.changeset(attrs)
  |> Repo.insert()

Could be:

%{ item | id: nil, insterted: nil, updated_at: nil }
|> __MODULE__.changeset(attrs)
|> Repo.insert()

The typo would have thrown the following error:

** (KeyError) key :insterted_at not found in: %{id: 1, inserted_at: ...}

@mischa-s
Copy link

@samhstn the way you suggested looks great, so easy to read 😸

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help wanted Extra attention is needed question Further information is requested technical
Projects
None yet
Development

No branches or pull requests

5 participants