Adding calculated field on save

I’m working on a table to store shipping addresses. I would like to add a fingerprint of each address so that I can more easily tell whether or not a given address has already been added. For simplicity, let’s assume that I’ve worked out a nice fingerprinting algorithm to normalize my addresses and identify them via a single string… let’s say this function is named simply fingerprint(address).

My question is about Ecto and changesets: how and where can I add this field to the database record when I save it? I’m unclear on how the cast and other functions are working inside the changeset function.

If I try to save a new address and its fingerprint matches an existing address, the save operation should fail.
If I try to edit an existing address and change it so it matches an existing address, the update operation should fail.

Any help on this would be appreciated… I’m having a hard time wrapping my head around where to add this and how to think about it in an idiomatic way. Thanks!

2 Likes

Probably something like this…

  @doc false
  def changeset(whatever, attrs) do
    whatever
    |> changeset(attrs)
    |> cast(attrs, @fields)
    |> validate ...
    |> fingerprint()
    |> unique_constraint(:fingerprint, message: "Fingerprint already in use")
  end

  defp fingerprint(changeset) do
    case changeset do
      %Ecto.Changeset{valid?: true, changes: %{address: address}} ->
        put_change(changeset, :fingerprint, create_fingerprint(address))
      _ ->
        changeset
    end
  end

  defp create_fingerprint(address) do
    # Implement your fingerprint here
  end

It is not tested, and fields might not correspond to your structure, but You can get the idea… the 2 last lines from changeset are important.

3 Likes

I’d also recommend you store the fingerprint in the database and add a unique constraint and a non-null constraint on the column.

1 Like
defp fingerprint(changeset) do
    case changeset do
      %Ecto.Changeset{valid?: true, changes: %{address: address}} ->
        put_change(changeset, :fingerprint, create_fingerprint(address))
      _ ->
        changeset
    end
  end

Whoa. Mind blown. First off, thank you – this is way further than I would have gotten even after pouring over the Ecto docs. This code does execute, but the “fingerprint” value does not get set. I can test the create_fingerprint() function and verify that it returns a string value when given a map representing an address. I confess that I do not understand much of anything (including the syntax) of the defp fingerprint() function in your example. Can anyone walk me through that code? Or maybe someone can spot why that value isn’t persisting?

As always, many thanks.

You might show your schema, and migration file. Be sure :fingerprint field exists, as string…

You might also insert some IO.inspect in your pipeline to show the changeset, like so

  def changeset(whatever, attrs) do
    whatever
    |> changeset(attrs)
    |> cast(attrs, @fields)
    |> validate ...
    |> IO.inspect()
    |> fingerprint()
    |> IO.inspect()
    |> unique_constraint(:fingerprint, message: "Fingerprint already in use")
  end

The function fingerprint take a changeset, and if it is valid, it should persists the change.

If it is clearer, You could have used pattern matching, like so.

defp fingerprint(%Ecto.Changeset{valid?: true, changes: %{address: address}} = changeset) do
  put_change(changeset, :fingerprint, create_fingerprint(address))
end
defp fingerprint(changeset), do: changeset

BTW Try to change the name of the function to generate_fingerprint(), just to be sure it does not conflict with the field name.

2 Likes
defmodule Address.AddressCtx.Address do
  use Ecto.Schema
  import Ecto.Changeset

  schema "addresses" do
    field :street1, :string, size: 250, default: ""
    field :street2, :string, size: 250, default: ""
    field :city, :string, size: 100, default: ""
    field :state, :string, size: 2, default: ""
    field :zip, :string, size: 32, default: ""
    field :country, :string, size: 3, default: "US"
    field :fingerprint, :string, size: 32, default: ""
    timestamps()
  end

  @doc false
  def changeset(address, attrs) do
    address
    |> cast(attrs, [:street1, :street2, :city, :state, :zip, :country, :fingerprint])
    |> validate_required([:street1, :city, :state, :zip, :country])
    |> append_fingerprint()
    |> unique_constraint(:fingerprint, message: "Fingerprint already in use")
  end


  defp append_fingerprint(changeset) do
    case changeset do
      %Ecto.Changeset{valid?: true, changes: %{address: address}} ->
        IO.puts(address)
        put_change(changeset, :fingerprint, get_fingerprint(address))
      _ ->
        changeset
    end
  end


  @doc """
   Simple md5 fingerprint
  """
  def get_fingerprint(address) do
    address.street1 <> address.street2 <> address.city <> address.state <> address.zip <> address.country
    |> String.upcase
    |> :erlang.md5
    |> Base.encode16(case: :lower)
  end

end

Here’s my schema file – the get_fingerprint() function seems to work when I test it in iex, so I’m thinking I’ve missed something in the cast() or other functions inside changeset since I’m not fully understanding the pieces of that.

Are there times when you would want to define a field in the database but not list it in the fields passed to cast()?

Do not cast :fingerprint… it will be calculated :slight_smile:

2 Likes

I tried omitting that, but the result is the same. I guess I don’t understand what cast() is really doing (or the @fields macro for that matter)… even if I include a value for for the fingerprint, my expectation is that it would be overwritten anyhow.

The @fields is just equal to a list of fields, as your [:street1, :street2, :city, :state, :zip, :country, :fingerprint]

cast() transforms data from the outside world, to the inner struct.

Did You get some logs from IO.inspect()? I am curious to see how the changeset looks.

1 Like

This should not work… as it should be a field

You should use

%Ecto.Changeset{valid?: true, changes: %{street1: street1, street2: street2...}}

# or

%Ecto.Changeset{valid?: true, changes: address}

In the example I gave, whatever is the shemas, and address is just a field of %Whatever{}

You might find ecto changeset structure here. From docs…

changes - The changes from parameters that were approved in casting

3 Likes

You can also use get_field(changeset, key, default \ nil) to check against changes and then the changeset data for your address prop. You issue may be be that address is not in the changes obj.

address is not in the change object. address is the change object :slight_smile:

it is not a property, it is the schema.

Thank you all! I got it working and I learned a lot in the process – inspecting the changeset with IO.inspect() was helpful because I could see the diffs and where fields were being included in or omitted from the changeset. I ran into a bit of trouble because I had set a default value for country, so that would cause that key to be missing from the changeset. Similarly, with the optional “street2” value, if it was sent with a blank value or omitted entirely, the changeset would end up without that field.

1 Like