Adding a `soft_delete` to Ecto Multi pipelines

I’m a big fan of Ecto, Elixir’s database wrapper. The Multi library lets you build up a series of operations that happen in order, if one fails the entire operation rolls back. Multi comes with the a lot of standard CRUD built in, insert/4 , update/4 , delete/4 and their bulk counterparts insert_all/5 , update_all/5 and delete_all/5 for acting on multiple records.

I’ve been working on a project where we make use of the soft delete pattern, rather than calling delete/4 on a record we generally update/4 the record passing in a deleted_at timestamp:

|> Multi.update(:soft_delete, fn %{customer: customer} -> 
	Changeset.change(customer, %{deleted_at: now})
end)

This works fine, and even updating multiple records one could take this approach:

|> Multi.update_all(:soft_delete, fn %{customers: customers} ->
	ids = Enum.map(customers, & &1.id)
	from(c in Customer, where: c.id in ^ids, update: [set: [deleted_at: ^now]])
end, [])

I was working on a new feature that will require a cascade of soft deletes, deleting multiple records, their associated records, their children, etc. (As the second example above is doing). Admittedly, I could have just utilized this Multi.update_all/5 and put multiple steps into the multi . However; I thought continuously mapping specific ids, passing in set: [deleted_at: ^now] was a little cumbersome and not very idiomatic. Mostly, I wanted to have a bit of fun wondering: “what if Ecto.Multi had a soft_delete_all/5 function?” Of course it doesn’t, this is a niche use case but it makes sense in this application so I dug in and found the task to be (as is the case with a lot of Elixir) surprisingly easy.

Just like update_all/5 I wanted to make sure soft_delete_all would handle queries or functions passed in. Pattern matching here using the is_function/1 guard. This made it a fairly straightforward operation:

@spec soft_delete_all(Multi.t(), atom(), fun() | Query.t(), keyword()) :: Multi.t()
  def soft_delete_all(multi, name, func, opts \\ [])

  def soft_delete_all(multi, name, func, opts) when is_function(func) do
    Multi.run(
      multi,
      name,
      operation_fun({:soft_delete_all, func, [set: [deleted_at: Timex.now()]]}, opts)
    )
  end

  def soft_delete_all(multi, name, queryable, opts) do
    add_operation(multi, name, {:update_all, queryable, [set: [deleted_at: Timex.now()]], opts})
  end

The first function matches against functions while the second matches against a queryable. I’ll explain the distinction between both.

Under the hood Multi is already equipped to handle functions or queryables; by reading the source of the Multi module I was able to,matches, forward along the proper structure for the Multi to run, and in another case recreate the same functionality that Multi.update_all uses. Both operation_fun/2 and add_operation/3 are nearly copy-pasted from the Multi core.

In the first instance the multi is passed a function, something like:

|> soft_delete_all(:remove_customer, &remove_customer/1)

In this case Ecto adds a new Multi operation to the pipeline: Multi.run/3 but it needs to run the function it’s passed. It does this with operation_fun/2 . The multi has several matchers for each of the bulk operations, in my case I only needed one :soft_delete_all .

defp operation_fun({:soft_delete_all, fun, updates}, opts) do
    fn repo, changes ->
      {:ok, repo.update_all(fun.(changes), updates, opts)}
    end
  end

Again, this is identical (save the :soft_delete_all atom) to the Multi module. It runs our function which creates a query, it passes our update: [set: [deleted_at: Timex.now()]] to the query and then updates the record.

In cases where we pass a query in:

|> soft_delete_all(:remove_customer, Query.from(c in Customer, where: c.id == 123))

We match on the next function head, here again I used Ecto’s pattern writing my own custom add_operation/3

defp add_operation(%Multi{} = multi, name, operation) do
    %{operations: operations, names: names} = multi

    if MapSet.member?(names, name) do
      raise "#{Kernel.inspect(name)} is already a member of the Ecto.Multi: \n#{Kernel.inspect(multi)}"
    else
      %{multi | operations: [{name, operation} | operations], names: MapSet.put(names, name)}
    end
  end

This is going to first check that the operation name isn’t already in the Multi. If it’s not, we append the operation into the Multi. This works because of the parameters we’ve passed it:

add_operation(multi, name, {:update_all, queryable, [set: [deleted_at: Timex.now()]], opts})
  end

Specifically: {:update_all, queryable, [set: [deleted_at: Timex.now()]], opts} once again, we aren’t doing anything fancy to soft delete these records, we are using Multi’s ability to :update_all with our provided queryable. The update we are making is [set: [deleted_at: Timex.now()]] .

There you have it, it’s :update_all all the way down, which makes sense because we are updating a record instead of deleting it, but I think it’s a lot cleaner to write something like this:

query1 = from(c in Customer, where: c.last_purchase <= ^old_date)
query2 = from(u in User, join: c in assoc(u, :customer), on: c.last_purchase <= ^old_date)

Multi.new()
|> soft_delete_all(:customers, query1)
|> soft_delete_all(:users, query2)
#πŸ‘†don't judge this contrived example it's not production code

TIL Struct matching in Guards

Not so much a TIL but I always get confused with the proper syntax. You can pattern match on a struct and use it in a guard to only let through the structs you want:

@spec address_formater(BillAddress.t() | ShipAddress.t()) :: String.t()
def address_formatter(%struct{} = address) when struct in [BillAddress, ShipAddress] do
 ...
end 

def address_formatter(_), do: raise "AddressError :: Not my address!"

As with a lot of my examples it may be a little contrived but it is based on a real world but I fixed today where address_formatter/2 was getting an %Ecto.Association.NotLoaded{} and trying to format it.

I’m 33 years old and I only learned how to spell “doesn’t” this year. Getting enough practice where I almost spell it right the first time. (I think I used to default to dosen’t).

TIL UUIDv4 vs UUIDv7

I’ve always run with UUID v4 because it’s the default for the Ecto.UUID library in Elixir. However a coworker recommended UUID v7. Having never really looked into UUID other than to implement as a primary key the distinction was news to me.

Effectively;

  • UUID v4 is a totally random hash that is generated and extremely unlikely to ever conflict with any other generated UUID.
  • UUID v7 also contains a random hash but is also based on a timestamp, this means you can sort them and index them.

For further reference, yes there are UUIDs v1-v8 as of this writing. If you want a good description of each you can check out this helpful link .

TIL INSERT INTO with SELECT constraints

In the past month I’ve had to write a lot of SQL to migrate a system and split existing “locations” into tenants ie. migrating data from a public schema to a tenant’s schema is gets messy due to foreign key constraints. Order of operations is important but sometimes you still find yourself in a corner.

In instances where I already have data in the tenant schema, for example customers and I need to load a subset of data from another table, eg. customer_addreses it’s possible to run the query with tenant.customers as a constraint for what your inserting:

INSERT INTO tenant.customer_addresses SELECT * FROM public.customer_addresses AS pc WHERE EXISTS (SELECT 1 FROM tenant.customers AS tc WHERE tc.id == pc.customer_id)

This will insert public.customer_addresses into tenant.customer_addresses for every teant.customer that already exists. I’ve gotten around a lot of tricky constraint issues with missing/incomplete data this way.

July 2024 Music Recap

One of the things I miss most about Spotify is Wrapped. I always feel a pang of envy at the end of the year when people are sharing their Wrapped stats, to the point where I’ve thought about trying to hook into Tidal’s underdeveloped API and create something similar for myself. Alas, maybe someday, it’s about #5 on the project backlog.

In the meantime I make due with Tidal’s monthly email of my most streamed artists. I love this and I rarely have the same artist appear on the top five two months in a row. I’d like to say I’m constantly seeking out new artists but more often than not my most streamed artists are dictated by a singular mood that may prevail for a week or even a day. Listening to a few albums in a single day can really shoot an artist towards the top of the personal chart.

Without further ado:

  1. Car Seat Headrest - 47 songs
  2. Mondo Cozmo - 38 songs
  3. Broken Bells - 35 songs
  4. Wet Leg - 30 songs
  5. The Decemberists - 29 songs

You can see even Car Seat Headrest represents the equivalent of about four albums from a prolific catalog, this isn’t me sitting in my room for hours a day listening to an artist on repeat.

This month was mostly reliable listening, looking over my personal chart I wouldn’t look at this list and say it’s representative of my “favorites” but each one of these artists has something that really gets under my skin in the best way possible.

Car Seat Headrest

I only discovered this gem of a band a few years back. They present an odd mixture of DYI-kid-in-his-basement and anthemic pop hooks with a dash of Brand New style emo. While fans of the band have jumped down deep rabbit holes of mythology; frankly, I just like the music and the passion that Will Toledo manages to put into every word he sings. Call me basic but “Teens of Style” is my favorite album, I feel like it treads a fine line between their garage rock roots and their more avant-garde sounds.

Mondo Cozmo

Never on the heavy rotation, but in this day and age there is a rare quality about Mondo Cozmo: his albums feel extremely cohesive. He lacks the textures that I find in the early albums of, say, Bruce Springsteen but there aren’t any filler tracks. The melodies are catchy, and some of the cheesy poetry of lyrics can be forgiven as the music drowns them out. “New Medicine” or “This is For the Barbarians” are his best.

Broken Bells

Broken Bells is one of my favorites and how could they not be? I’m a big fan of The Shins early work and Danger Mouse has long been one of my most reliable producers so a collaboration between James Mercer and Brian Burton couldn’t go wrong. The first two albums (“Broken Bells” and “After The Disco”) are, essentially equal in my eyes, with their latest effort “Into the Blue” taking a close third (second?). That may be due to the problem of time and place, our experience with a band is forever frozen in the formative years when we first discovered them, all later work will be compared to this gilded memory.

The third ingredient that makes the Broken Bells potion sizzle is veteran Cartoon Network artist and designer Jacob Escobedo who does phenomenal artwork for them.

Wet Leg

Catchy hooks, witty (and naughty) lyrics blend to make a very fun listening experience. Wet Leg writes about all the alt-rock standards; wanting someone who doesn’t want you back, quarter-life crises, post breakup sex, and a general malaise of the human condition but they manage to just be a bit more witty than most. Chaise Lounge was their breakout single but almost any of the songs on 2022’s “Wet Leg” could be singles. I’m interested to see what comes next.

The Demberists

Sometimes I think The Decemberists are rebels from the young America who somehow managed to time travel to our age. This may, in part, be due to their recording the hilarious Hamilton discard; “Ben Fanklin’s Song”. More likely it’s the out of place nature of their music; it’s very much 00s indie rock (you may notice a theme with me here) but neither the subject matter nor the heavy acoustics seem to quite fit with the genre. That’s not to mention Colin Meloy’s voice, which is unlike any of his contemporaries.

Unlike Broken Bells, Wet Leg or Mondo Cozmo, I find The Decemberists’s catalog to be rather hit and miss. Every album has some standout songs, but every album also has a few that don’t connect at all with me.

Lets see what happens in August! Maybe some repeats (in the process of writing this up I’ve been listening to a lot of these artists over).

Why Cybertruck when you could Cyberduck ! I think I’ve been using Cyberduck for 100% of my FTP needs for at least 15 years. Such a rock solid piece of software.

SQL is the way, SQL is always the way! I killed myself for hours this morning trying to query/clean some data in Rails, started writing raw SQL and had it sorted in 30 minutes!

Apparently I destroyed my fingerprint while climbing over the weekend- my MBP fingerprint no longer seems to work. It’s a small feature but very missed when it doesn’t work.

Rainy Morning in Guelph ON

Morning Jog in Guelph. Rainy days like yesterday are perfect!