Writing ON CONFLICT Clauses on Partial Indexes using GORM
Table of Contents
Introduction⌗
PostgreSQL’s ON CONFLICT
clause is a useful construct that allows you to
handle cases where an insertion may violate a uniqueness constraint. It is
not standard SQL syntax, but an extension, like SQLite’s INSERT OR REPLACE
.
Let’s say we have a table named tasks
like this:
create table if not exists tasks
(
account_id integer not null,
task_name text not null,
task_desc text not null
);
-- Yes, I prefer lowercase SQL. Sue me
Say we want tasks to have unique names for each account. Let’s define a unique index for it:
create unique index if not exists unq_tasks_index
on tasks (account_id, task_name);
Let’s say we need to perform an upsert operation on this table. Maybe we have a wacky endpoint that either rewrites the description of an existing task, or creates one anew. I don’t know; I just want to get to the point.
To upsert; we can write the following query:
insert into tasks (account_id, task_name, task_desc)
values (1, 'test task', 'This is a test task')
on conflict (account_id, task_name)
do update set task_desc=excluded.task_desc;
This does one of two things, atomically:
- If an entry with the same
(account_id, task_name)
pair exists, it simply updates thetask_desc
field. - If such an entry does not exist, it inserts it.
excluded
is a keyword here; referring to the row that is newly being inserted.
(Perhaps proposed
or candidate
would be better names, but what do I know?)
We can also insert multiple rows this way:
insert into tasks (account_id, task_name, task_desc) values (1, 'test task', 'This is a test task'), (1, 'test task 2', 'Another task') on conflict (account_id, task_name) do update set task_desc=excluded.task_desc;
But there is an important edge case here! If the proposed rows conflict with each other, then you get the following error:
[21000] ERROR: ON CONFLICT DO UPDATE command cannot affect row a second time
This behavior is documented here:
INSERT with an ON CONFLICT DO UPDATE clause is a "deterministic" statement. This means that the command will not be allowed to affect any single existing row more than once; a cardinality violation error will be raised when this situation arises.
Be careful if you’re using this variant!
Raw query is fine and all, but if we wanted to achieve the same behavior using GORM,
we can use clauses.OnConflict
like this:
type Task struct {
AccountID uint
TaskName, TaskDesc string
}
task := Task{1, "test task", "This is a test task"}
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "account_id"}, {Name: "task_name"}},
DoUpdates: clause.AssignmentColumns([]string{"task_desc"}),
}).Create(&task)
Problem⌗
What if we wanted soft-delete functionality for this table? We’d need a column like is_deleted
, or deleted_at
:
create table tasks
(
account_id integer not null,
task_name text not null,
task_desc text not null,
-- added
deleted_at timestamp with time zone
);
That’s easy, but now our unique index does not make sense.
Now if we create a task named abc
, delete it, then create a new one;
the newly created entry will conflict with the deleted one!
We should keep that from happening using a partial index:
create unique index if not exists unq_tasks_index
on tasks (account_id, task_name)
-- added
where deleted_at is null;
Now uniqueness checks are performed only among non-deleted rows. Cool.
We should also modify our query. We add the deleted_at
column to
the column list, corresponding values, and the update set:
insert into tasks (account_id, task_name, task_desc, deleted_at)
values (1, 'test task', 'This is a test task', null)
on conflict (account_id, task_name)
do update set
task_desc=excluded.task_desc,
deleted_at=excluded.deleted_at;
After this, converting it to GORM is trivi-
[42P10] ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
Oh. We have an error. Of course.
Now that we have a partial index; our conflict condition does not match an existing index.
Fortunately, we can pass a where
clause as an
index predicate:
insert into tasks (account_id, task_name, task_desc, deleted_at)
values (1, 'test task', 'This is a test task', null)
on conflict (account_id, task_name)
-- added
where deleted_at is null
do update set
task_desc=excluded.task_desc,
deleted_at=excluded.deleted_at;
Then the partial index is properly inferred, and the query works as intended.
Converting this to GORM is trivial:
type Task struct {
AccountID uint
TaskName, TaskDesc string
DeletedAt time.Time
}
task := Task{AccountID: 1, TaskName: "test task", TaskDesc: "This is a test task"}
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "account_id"}, {Name: "task_name"}},
DoUpdates: clause.AssignmentColumns([]string{"task_desc"}),
// added
TargetWhere: clause.Where{Exprs: []clause.Expression{
clause.Eq{Column: "deleted_at", Value: nil},
}},
}).Create(&task)
The TargetWhere
field of clauses.OnConflict
lets us provide the index predicate here.
Do not confuse it with the
Where
field, which is a regularWHERE
that goes at the end of the clause.
We can perform atomic upsert operations on a table with a partial index using GORM like this.
Why did I write a blog post on this?⌗
Because the TargetWhere
field does not exist in GORM version v1.21.3, which is what I had to use in a
project due to, uhh, reasons. Mind that the latest version is > v1.30; and the problem has long been
addressed with this commit,
four years ago (The joys of enterprise software).
I ended up writing a temporary duplicate for the OnConflict
clause like this,
along with a TODO message strongly suggesting we update our dependencies.
Note that I used the same field names as clauses.OnConflict
in v1.30.1,
so I can simply replace this with the original clause once our dependency is upgraded.
This version is also missing other unused fields, and some value checks etc. for brevity:
type PartialOnConflict struct {
Columns []clause.Column
TargetWhere clause.Where
DoUpdates clause.Set
}
func (c PartialOnConflict) Build(b clause.Builder) {
b.WriteByte('(')
for i, e := range c.Columns {
if i > 0 {
b.WriteByte(',')
}
b.WriteQuoted(e)
}
b.WriteString(") WHERE ")
c.TargetWhere.Build(b)
b.WriteString(" DO UPDATE SET ")
c.DoUpdates.Build(b)
}
func (PartialOnConflict) Name() string { return "ON CONFLICT" }
func (c PartialOnConflict) MergeClause(m *clause.Clause) { m.Expression = c }
As far as I could see, the issue and the corresponding fix in GORM was not explicitly documented in the changelog, but I might have missed it too. If you found yourself in a similar situation, I hope this blog post explains the situation, and encourages you to keep your dependencies up-to-date.
The rest of you, I wish you productive days. Go now.