dm80439

dm80439

I have a soft delete column in my table (deleted_at). I am trying to work out how to write an “upsert” using Repo.insert/2 with the on_conflict and conflict_target options that respects the deleted_at column (i.e., treats a row with a non-null deleted_at value as if the row didn’t exist for purposes of upserting).

In other words, I want the upsert to insert if there is no record at all or if there is no record where deleted_at is NULL, and update if there is a record and deleted_at is NULL.

For a concrete example, let’s say I have an association table that records relationships between Companies and Programs:

  schema "company_programs" do
    field :leader, :string
    field :deleted_at, :utc_datetime
    timestamps(type: :utc_datetime)
    belongs_to :company, Company
    belongs_to :program, Program
  end

Further, my migration establishes a uniqueness constraint on the combination of company_id and program_id.

And let’s say I have the following record in that table:

id | company_id | program_id | leader |     inserted_at     |      updated_at     |     deleted_at
---|------------|------------|--------|---------------------|---------------------|---------------------
 1 |     1      |     5      |   Mary | 2021-07-13 12:00:00 | 2021-07-15 12:00:00 | 2021-08-01 12:00:00

I thought the upsert code should look something like:

on_conflict = from(r in CompanyPrograms, where: is_nil(r.deleted_at), update: [set: [leader: ^leader]])
Repo.insert(
  conflict_target: [:company_id, :program_id],
  on_conflict: on_conflict,
  returning: true
)

but using this code, if I run an upsert using the same company_id and program_id as show above, the result is the error “(Ecto.StaleEntryError) attempted to insert a stale struct” since the set of records matching the conflict criteria is the empty set (company_id matches, program_id matches but deleted_at is not null).

Is it possible to structure this upsert so that, for the record above, doing a subsequent upsert with the same company_id and program_id would insert a NEW record in the table with a different PK but the same company_id and program_id as the above record?

Thanks,

Dave

Showing Posts 1 to 4

qhwa

qhwa

What about a conditional unique index?

dm80439

dm80439 OP

Thanks! I will try that out and follow up here with results for posterity.

dm80439

dm80439 OP

@qhwa is right. The unique index on company_id and program_id needed to have a where clause excluding soft deleted rows - a partial index.

This makes total sense (anyway) since I do want to allow the possibility of another record with the same combination of company_id and program_id to be created after one is soft deleted.

So now, in my contrived example, the index looks like this:

CREATE UNIQUE INDEX company_programs_company_id_program_id_index
    ON public.company_programs USING btree
    (company_id ASC NULLS LAST, program_id ASC NULLS LAST)
    TABLESPACE pg_default
    WHERE deleted_at IS NULL;

and the upsert logic looks like this:

Repo.insert(
  entity,
  conflict_target: {:unsafe_fragment, "(company_id, program_id) WHERE deleted_at is null"},
  on_conflict: {:replace, [:leader]},
  returning: true
)

I did verify that:

  1. After soft deleting a record (i.e., entering a date into the deleted_at column of the row), this upsert logic will insert a new record with the same company_id and program_id as the soft deleted row.
  2. That upserting for a row that is not soft deleted does update the row.
  3. Removing the where deleted_at is null in the conflict target expression breaks the upsert and results in an error (Postgrex.Error) ERROR 42P10 (invalid_column_reference) there is no unique or exclusion constraint matching the ON CONFLICT specification

Thanks again @qhwa

qhwa

qhwa

Awesome! I haven’t tried it before so I learned it today from you. Thank you for sharing the full solution!

— All posts loaded —

Where Next? Top

Trending in Questions Top

RSP87
I’m working on a project that simulates the bumbl example in the programming phoenix book. It acts almost like an email client. We have a...
New
kszambelanczyk
Hello! Could someone please give me a help/sample code, how to delete a file from s3 using waffle/waffle_ecto from Phoenix app. I creat...
New
RemyXRenard
I’m seeing that a list inside a Kino.DataTable will be interpreted as a charlist, even if the Kino.configure() is set to charlists: :as_l...
New
velrest
So my question is quite simple and i have found no conclusive answer on forum, google or AI. Should we use :erlang.float for Integer to ...
New
samoloth
Hi, I’ve just set up an application with ash_authentication. There is only magic link strategy for now, so there is no confirmation add o...
New
FlyingNoodle
If a change or preparation module uses Ash.Changeset.get_argument/2 or Ash.Query.get_argument/2 (or any of the other get_argument functio...
New
ryanwinchester
apply_graft/2 doesn’t rewrite an add_many sub-workflow’s deps on an add step. Grafted jobs cancel with “upstream job was deleted” Version...
New

Other Trending Topics Top

mudasobwa
I am happy to introduce the very α version of the new programming language compiled to BEAM. Welcome Cure. It has literally three kille...
New
marciok
Hi there! We created Gust: A task orchestrator inspired by Airflow. For those who have never heard about Aiflow, it’s a Python-based wor...
New
jimsynz
Beam Bots (or just BB for short) is a framework for building fault-tolerant robotics applications in Elixir using familiar OTP patterns. ...
New
Dmk
Xamal is a deployment tool for Elixir apps that deploys native releases to bare metal servers over SSH. It’s a port of GitHub - basecamp/...
New
Damirados
Hello everyone. After busy few months I am happy to announce v0.1.0 of Emerge & Solve. They are GUI (Emerge) and State management (S...
New
netoum
Corex is an accessible, unstyled UI component library for Phoenix that integrates Zag.js state machines using Vanilla JavaScript and Live...
New

We're in Beta

About us Mission Statement

Options

Thread Display Mode




Thread Preview

Skip Thread Previews