fix(studio): match composite primary keys as tuples when deleting rows (#51575)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Bug fix

## What is the current behavior?

Fixes #51574

Deleting several selected rows from a table with a composite primary key
builds one `IN` list per key column:

```sql
delete from public.user_roles where user_id in (1,2) and role_id in ('admin','viewer');
```

That matches the cartesian product of the selected values, so rows the
user never selected are deleted too. In the example above, selecting
`(1, 'admin')` and `(2, 'viewer')` also deletes `(1, 'viewer')` and `(2,
'admin')`.

## What is the new behavior?

When there is more than one primary-key column, the delete uses the
tuple `IN` filter that pg-meta already supports:

```sql
delete from public.user_roles where (user_id, role_id) in ((1, 'admin'), (2, 'viewer'));
```

Single-column primary keys keep the same SQL as before. The batched
operation queue uses the same helper for its delete operations, so it is
covered as well.

Added unit tests for both the single and composite cases.

## Additional context

This is silent data loss on join tables, which are the most common place
for composite keys and for multi-row deletes.
This commit is contained in:
Sahil Jagtap authored and GitHub committed 2026-10-14 19:24:36 +00:00
1 parent cfdc81e1fe
commit 135d8f00c3
2 files changed
+61 -6

No files matched your search

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { getTableRowDeleteSql } from './table-row-delete-mutation'
import type { Entity } from '@/data/table-editor/table-editor-types'
function createTable(primaryKeys: string[]): Entity {
return {
id: 1,
name: 'user_roles',
schema: 'public',
entity_type: 'r',
primary_keys: primaryKeys.map((name) => ({ name, schema: 'public', table_name: 'user_roles' })),
} as unknown as Entity
}
describe('getTableRowDeleteSql', () => {
it('deletes by a single primary key with an in list', () => {
const sql = String(
getTableRowDeleteSql({
table: createTable(['id']),
rows: [
{ idx: 0, id: 1 },
{ idx: 1, id: 2 },
],
})
)
expect(sql).toBe('delete from public.user_roles where id in (1,2);')
})
it('deletes only the selected rows when the primary key is composite', () => {
const sql = String(
getTableRowDeleteSql({
table: createTable(['user_id', 'role_id']),
rows: [
{ idx: 0, user_id: 1, role_id: 'admin' },
{ idx: 1, user_id: 2, role_id: 'viewer' },
],
})
)
// Must match the row tuples, not each column independently. Matching
// columns independently would also delete (1, 'viewer') and (2, 'admin').
expect(sql).toBe(
"delete from public.user_roles where (user_id, role_id) in ((1, 'admin'), (2, 'viewer'));"
)
})
})
@@ -29,13 +29,20 @@ export function getTableRowDeleteSql({
const { primaryKeys, error } = getPrimaryKeys({ table })
if (error) throw error
let queryChains = new Query().from(table.name, table.schema ?? undefined).delete()
primaryKeys?.forEach((key) => {
const primaryKeyValues = rows.map((x) => x[key])
queryChains = queryChains.filter(key, 'in', primaryKeyValues)
})
const queryChains = new Query().from(table.name, table.schema ?? undefined).delete()
if (!primaryKeys || primaryKeys.length === 0) return queryChains.toSql()
return queryChains.toSql()
if (primaryKeys.length === 1) {
const [key] = primaryKeys
const values = rows.map((row) => row[key])
return queryChains.filter(key, 'in', values).toSql()
}
// Composite primary keys must be matched as whole tuples. Filtering each key
// column independently matches the cartesian product of the selected values
// and would delete rows that were never selected.
const tuples = rows.map((row) => primaryKeys.map((key) => row[key]))
return queryChains.filter(primaryKeys, 'in', tuples).toSql()
}
export async function deleteTableRow({