Files
supabase/apps/studio/components/interfaces/Explorer/ExplorerNotebookTab.utils.test.ts
Joshen Lim 4dc973048d Add confirmation modal when running notebook if notebook contains query cells that aren't read only (#49376)
## Context

Adds a confirmation modal when hitting "run notebook" if the notebook
contains any query cells that involve any sort of mutation (insert,
update, alter, etc, etc). Also gives users the option to run the
notebook's read only cells as an alternative.

<img width="432" height="355" alt="image"
src="https://github.com/user-attachments/assets/0413a3ad-5419-4c83-8bf3-976bfa683b9a"
/>


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added confirmation prompts before running queries that may modify data
or database structure.
* Prompts identify potentially mutating notebook queries and allow
running read-only cells instead.
* Query execution now includes checks for destructive operations and
missing row-level security, with optional automatic setup.
* Notebook runs use the latest saved and unsaved SQL and reliably reset
execution status.

* **Bug Fixes**
* Improved notebook layout behavior so content shrinks correctly within
flexible sections.

* **Tests**
* Expanded coverage for mutation detection, comments, multiple
statements, live SQL, and cell filtering.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-24 14:15:56 +08:00

128 lines
4.0 KiB
TypeScript

import { untrustedSql } from '@supabase/pg-meta'
import { describe, expect, it } from 'vitest'
import { findMutatingQueryCells, isMutatingSql } from './ExplorerNotebookTab.utils'
import { type Cell } from '@/data/content/notebooks/notebook-schema'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
describe('isMutatingSql', () => {
it('returns false for a read-only query', () => {
expect(isMutatingSql('select * from auth.users')).toBe(false)
})
it.each([
'insert',
'update',
'delete',
'create',
'alter',
'drop',
'truncate',
'grant',
'revoke',
'merge',
])('returns true when the SQL starts with %s', (keyword) => {
expect(isMutatingSql(`${keyword} something`)).toBe(true)
})
it('is case-insensitive', () => {
expect(isMutatingSql('DELETE FROM auth.users')).toBe(true)
})
it('returns true when a mutating statement follows a read-only one', () => {
expect(isMutatingSql('select 1; delete from auth.users')).toBe(true)
})
it('ignores mutating keywords inside comments', () => {
expect(isMutatingSql('-- delete this table later\nselect 1')).toBe(false)
expect(isMutatingSql('/* create table foo */\nselect 1')).toBe(false)
})
})
describe('findMutatingQueryCells', () => {
const readOnlyDatabaseCell: Cell = {
_tag: 'database_cell',
_id: 'cell-1',
title: 'Signups',
view: 'table',
unchecked_sql: untrustedSql('select * from auth.users'),
row_limit: 50,
}
const mutatingDatabaseCell: Cell = {
_tag: 'database_cell',
_id: 'cell-2',
title: 'Cleanup',
view: 'table',
unchecked_sql: untrustedSql('delete from auth.users where id = 1'),
row_limit: 50,
}
const mutatingLogCell: Cell = {
_tag: 'log_cell',
_id: 'cell-3',
title: 'Edge logs',
view: 'table',
unchecked_sql: untrustedLogSql('insert into edge_logs values (1)'),
time_range: { _tag: 'relative_time_range', unit: 'hour', amount: 1 },
}
const markdownCell: Cell = {
_tag: 'markdown_cell',
_id: 'cell-4',
text: 'insert some notes here',
}
it('returns an empty array when there are no mutating database cells', () => {
expect(findMutatingQueryCells({ cells: [readOnlyDatabaseCell, markdownCell] })).toEqual([])
})
it('flags mutating database cells with their id and title', () => {
expect(findMutatingQueryCells({ cells: [readOnlyDatabaseCell, mutatingDatabaseCell] })).toEqual(
[{ id: 'cell-2', title: 'Cleanup' }]
)
})
it('excludes log cells even when their SQL looks mutating', () => {
expect(findMutatingQueryCells({ cells: [mutatingDatabaseCell, mutatingLogCell] })).toEqual([
{ id: 'cell-2', title: 'Cleanup' },
])
})
it('falls back to "Untitled query" when a mutating cell has no title', () => {
const untitledCell: Cell = { ...mutatingDatabaseCell, title: undefined }
expect(findMutatingQueryCells({ cells: [untitledCell] })).toEqual([
{ id: 'cell-2', title: 'Untitled query' },
])
})
it('flags a cell whose live SQL mutates even though the stored SQL is read-only', () => {
const getLiveSql = (cellId: string) =>
cellId === 'cell-1' ? 'delete from auth.users' : undefined
expect(
findMutatingQueryCells({ cells: [readOnlyDatabaseCell, mutatingDatabaseCell], getLiveSql })
).toEqual([
{ id: 'cell-1', title: 'Signups' },
{ id: 'cell-2', title: 'Cleanup' },
])
})
it('flags a cell whose stored SQL mutates even when its live SQL looks read-only', () => {
const getLiveSql = (cellId: string) =>
cellId === 'cell-2' ? 'select * from auth.users' : undefined
expect(
findMutatingQueryCells({ cells: [readOnlyDatabaseCell, mutatingDatabaseCell], getLiveSql })
).toEqual([{ id: 'cell-2', title: 'Cleanup' }])
})
it('falls back to the stored SQL when the live getter has nothing for a cell', () => {
const getLiveSql = () => undefined
expect(
findMutatingQueryCells({ cells: [readOnlyDatabaseCell, mutatingDatabaseCell], getLiveSql })
).toEqual([{ id: 'cell-2', title: 'Cleanup' }])
})
})