refactor(sql-editor): extract deriveSnippetIdentity, debug/completion/diff-key helpers (#48014)

## Summary
Pure-fn extraction pass across the SQL editor hooks.

- Extracts `deriveSnippetIdentity` out of `useSnippetIdentity`'s inline
id + `isLoading` derivation into `SQLEditor.utils.ts`.
- Extracts `extractDebugContext` (shared snippet/result/error
extraction) and `buildDebugChatArgs` (the `aiSnap.newChat(...)` payload
builder) out of `useSqlEditorAi`'s `buildDebugPrompt`/`onDebug` into
`SQLEditor.utils.ts`.
- Extracts `buildCompletionRequestBody` (the AI completion endpoint's
request body builder) and `planDiffRequestApplication` (the
pending-diff-request application decision: replace vs. open a diff,
depending on whether the editor is currently empty) out of
`useSqlEditorAi` into `SQLEditor.utils.ts`. The `drainDiffRequest`
effect now just applies the plan instead of branching inline.
- Extracts `resolveDiffKeyAction` out of `useSqlEditorShortcuts`'s
window-keydown Enter/Escape branch into `SQLEditor.utils.ts`.

## Test plan
- [x] `pnpm --filter studio typecheck`
- [x] `pnpm test:studio -- SQLEditor` (265 tests passing)
- [x] `pnpm --filter studio run lint:ratchet`
This commit is contained in:
Charis authored and GitHub committed 2026-07-21 13:47:40 -04:00
1 parent cd5935d3e2
commit cdc843dadd
5 files changed
+446 -45

No files matched your search

@@ -2,25 +2,31 @@ import { safeSql, untrustedSql } from '@supabase/pg-meta'
import { stripIndent } from 'common-tags'
import { describe, expect, it, test } from 'vitest'
import { untitledSnippetTitle } from './SQLEditor.constants'
import type { IStandaloneCodeEditor } from './SQLEditor.types'
import { sqlAiDisclaimerComment, untitledSnippetTitle } from './SQLEditor.constants'
import { DiffType, type IStandaloneCodeEditor } from './SQLEditor.types'
import {
analyzeQueryIssues,
appendEnableRLSStatements,
applyAutoLimit,
assembleCompletionDiff,
buildCompletionRequestBody,
buildDebugChatArgs,
buildDebugPromptText,
buildExecuteParams,
checkAlterDatabaseConnection,
checkDestructiveQuery,
computeErrorHighlightLine,
deriveSnippetIdentity,
extractDebugContext,
filterTablesCoveredByEnsureRLSTrigger,
getCreateTablesMissingRLS,
getEditorSql,
hasActiveEnsureRLSTrigger,
hasBlockingIssues,
isUpdateWithoutWhere,
planDiffRequestApplication,
resolveConnectionString,
resolveDiffKeyAction,
shouldAutoGenerateTitle,
trimTrailingSemicolons,
} from './SQLEditor.utils'
@@ -290,6 +296,257 @@ describe('SQLEditor.utils.ts:buildExecuteParams', () => {
})
})
describe('SQLEditor.utils.ts:deriveSnippetIdentity', () => {
test('uses the generated id when there is no url id, loading reflects the snippets map like any other id', () => {
const result = deriveSnippetIdentity({
urlId: undefined,
generatedId: 'generated-id',
snippets: {},
})
expect(result).toEqual({ id: 'generated-id', isLoading: true })
})
test('uses the generated id and is not loading once it appears in the snippets map', () => {
const result = deriveSnippetIdentity({
urlId: undefined,
generatedId: 'generated-id',
snippets: { 'generated-id': { snippet: { content: { some: 'content' } } } },
})
expect(result).toEqual({ id: 'generated-id', isLoading: false })
})
test('uses the generated id and is never loading when the url id is "new"', () => {
const result = deriveSnippetIdentity({
urlId: 'new',
generatedId: 'generated-id',
snippets: { 'generated-id': { snippet: { content: undefined } } },
})
expect(result).toEqual({ id: 'generated-id', isLoading: false })
})
test('uses the url id and is loading when the snippet is not yet in the store', () => {
const result = deriveSnippetIdentity({
urlId: 'existing-id',
generatedId: 'generated-id',
snippets: {},
})
expect(result).toEqual({ id: 'existing-id', isLoading: true })
})
test('uses the url id and is loading when the snippet has no content yet', () => {
const result = deriveSnippetIdentity({
urlId: 'existing-id',
generatedId: 'generated-id',
snippets: { 'existing-id': { snippet: { content: undefined } } },
})
expect(result).toEqual({ id: 'existing-id', isLoading: true })
})
test('uses the url id and is not loading once the snippet content has arrived', () => {
const result = deriveSnippetIdentity({
urlId: 'existing-id',
generatedId: 'generated-id',
snippets: { 'existing-id': { snippet: { content: { some: 'content' } } } },
})
expect(result).toEqual({ id: 'existing-id', isLoading: false })
})
})
const buildDebugSnippet = (uncheckedSql: string) => ({
snippet: { content: { unchecked_sql: untrustedSql(uncheckedSql) } },
})
describe('SQLEditor.utils.ts:extractDebugContext', () => {
test('strips the AI disclaimer comment and trims the sql', () => {
const snippet = buildDebugSnippet(`${sqlAiDisclaimerComment}\n\nselect 1;`)
const result = { error: { message: 'relation does not exist' } }
expect(extractDebugContext(snippet, result)).toEqual({
sql: 'select 1;',
errorMessage: 'relation does not exist',
})
})
test('falls back to an empty sql when the snippet is undefined', () => {
expect(extractDebugContext(undefined, { error: { message: 'boom' } })).toEqual({
sql: '',
errorMessage: 'boom',
})
})
test('falls back to an empty sql when the snippet has no content yet', () => {
expect(
extractDebugContext({ snippet: { content: undefined } }, { error: { message: 'boom' } })
).toEqual({ sql: '', errorMessage: 'boom' })
})
test('falls back to "Unknown error" when the result is undefined', () => {
const snippet = buildDebugSnippet('select 1;')
expect(extractDebugContext(snippet, undefined)).toEqual({
sql: 'select 1;',
errorMessage: 'Unknown error',
})
})
test('falls back to "Unknown error" when the result has no error message', () => {
const snippet = buildDebugSnippet('select 1;')
expect(extractDebugContext(snippet, {})).toEqual({
sql: 'select 1;',
errorMessage: 'Unknown error',
})
})
})
describe('SQLEditor.utils.ts:buildDebugChatArgs', () => {
test('builds the newChat payload from the snippet sql and error message', () => {
const snippet = buildDebugSnippet('select 1;')
const result = { error: { message: 'relation does not exist' } }
expect(buildDebugChatArgs(snippet, result)).toEqual({
name: 'Debug SQL snippet',
sqlSnippets: ['select 1;'],
initialInput:
'Help me to debug the attached sql snippet which gives the following error: \n\nrelation does not exist',
})
})
})
describe('SQLEditor.utils.ts:buildCompletionRequestBody', () => {
test('builds the base request body with no extra options', () => {
expect(
buildCompletionRequestBody({
projectRef: 'default',
connectionString: 'postgresql://example',
orgSlug: 'acme',
})
).toEqual({
projectRef: 'default',
connectionString: 'postgresql://example',
language: 'sql',
orgSlug: 'acme',
})
})
test('merges options on top of the base fields', () => {
expect(
buildCompletionRequestBody({
projectRef: 'default',
connectionString: 'postgresql://example',
orgSlug: 'acme',
options: { completionMetadata: { prompt: 'add a where clause' } },
})
).toEqual({
projectRef: 'default',
connectionString: 'postgresql://example',
language: 'sql',
orgSlug: 'acme',
completionMetadata: { prompt: 'add a where clause' },
})
})
})
describe('SQLEditor.utils.ts:planDiffRequestApplication', () => {
test('replaces the editor content when it is empty', () => {
const plan = planDiffRequestApplication({
existingValue: '',
request: { diffType: DiffType.Modification, sql: 'select 1;' },
})
expect(plan).toEqual({ kind: 'replace', text: 'select 1;' })
})
test('opens a diff against the existing content when it is not empty', () => {
const plan = planDiffRequestApplication({
existingValue: 'select 0;',
request: { diffType: DiffType.Modification, sql: 'select 1;' },
})
expect(plan).toEqual({
kind: 'diff',
diff: { original: 'select 0;', modified: 'select 1;' },
diffType: DiffType.Modification,
})
})
test('carries through the requested diff type', () => {
const plan = planDiffRequestApplication({
existingValue: 'select 0;',
request: { diffType: DiffType.NewSnippet, sql: 'select 1;' },
})
expect(plan).toEqual({
kind: 'diff',
diff: { original: 'select 0;', modified: 'select 1;' },
diffType: DiffType.NewSnippet,
})
})
})
const keyEvent = (overrides: Partial<Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey'>> = {}) => ({
key: 'a',
metaKey: false,
ctrlKey: false,
...overrides,
})
describe('SQLEditor.utils.ts:resolveDiffKeyAction', () => {
test('does nothing when neither a diff nor the prompt is open', () => {
const action = resolveDiffKeyAction(keyEvent({ key: 'Escape' }), {
isDiffOpen: false,
isPromptOpen: false,
os: 'macos',
})
expect(action).toEqual({ type: 'none' })
})
test('does nothing for keys other than Enter/Escape', () => {
const action = resolveDiffKeyAction(keyEvent({ key: 'a' }), {
isDiffOpen: true,
isPromptOpen: false,
os: 'macos',
})
expect(action).toEqual({ type: 'none' })
})
test('accepts on Cmd+Enter on macOS when a diff is open', () => {
const action = resolveDiffKeyAction(keyEvent({ key: 'Enter', metaKey: true }), {
isDiffOpen: true,
isPromptOpen: false,
os: 'macos',
})
expect(action).toEqual({ type: 'accept' })
})
test('accepts on Ctrl+Enter on Windows when a diff is open', () => {
const action = resolveDiffKeyAction(keyEvent({ key: 'Enter', ctrlKey: true }), {
isDiffOpen: true,
isPromptOpen: false,
os: 'windows',
})
expect(action).toEqual({ type: 'accept' })
})
test('does not accept on Ctrl+Enter on macOS (wrong modifier for the OS)', () => {
const action = resolveDiffKeyAction(keyEvent({ key: 'Enter', ctrlKey: true }), {
isDiffOpen: true,
isPromptOpen: false,
os: 'macos',
})
expect(action).toEqual({ type: 'none' })
})
test('does not accept on Enter without the modifier key', () => {
const action = resolveDiffKeyAction(keyEvent({ key: 'Enter' }), {
isDiffOpen: true,
isPromptOpen: false,
os: 'macos',
})
expect(action).toEqual({ type: 'none' })
})
test('does not accept on Cmd+Enter when the diff is not open, even if the prompt is', () => {
const action = resolveDiffKeyAction(keyEvent({ key: 'Enter', metaKey: true }), {
isDiffOpen: false,
isPromptOpen: true,
os: 'macos',
})
expect(action).toEqual({ type: 'none' })
})
test('escapes with shouldDiscard true when a diff is open', () => {
const action = resolveDiffKeyAction(keyEvent({ key: 'Escape' }), {
isDiffOpen: true,
isPromptOpen: false,
os: 'macos',
})
expect(action).toEqual({ type: 'escape', shouldDiscard: true })
})
test('escapes with shouldDiscard false when only the prompt is open', () => {
const action = resolveDiffKeyAction(keyEvent({ key: 'Escape' }), {
isDiffOpen: false,
isPromptOpen: true,
os: 'macos',
})
expect(action).toEqual({ type: 'escape', shouldDiscard: false })
})
})
describe(`SQLEditor.utils.ts:checkDestructiveQuery`, () => {
it('drop statement matches', () => {
const match = checkDestructiveQuery('drop table films, distributors;')
@@ -15,7 +15,12 @@ import {
untitledSnippetTitle,
updateWithoutWhereRegex,
} from './SQLEditor.constants'
import { ContentDiff, type IStandaloneCodeEditor, type PotentialIssues } from './SQLEditor.types'
import {
ContentDiff,
DiffType,
type IStandaloneCodeEditor,
type PotentialIssues,
} from './SQLEditor.types'
import type { SnippetWithContent } from '@/data/content/sql-folders-query'
import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query'
import type { Database } from '@/data/read-replicas/replicas-query'
@@ -290,6 +295,26 @@ export function buildExecuteParams({
}
}
/**
* Derives the stable snippet id + loading state for the editor from the URL.
*/
export function deriveSnippetIdentity({
urlId,
generatedId,
snippets,
}: {
urlId: string | undefined
generatedId: string
snippets: Record<string, { snippet: { content?: unknown } }>
}): { id: string; isLoading: boolean } {
const id = !urlId || urlId === 'new' ? generatedId : urlId
const snippetIsLoading = !(id in snippets && snippets[id].snippet.content !== undefined)
const isLoading = urlId === 'new' ? false : snippetIsLoading
return { id, isLoading }
}
export const generateMigrationCliCommand = (id: string, name: string, isNpx = false) =>
`
${isNpx ? 'npx ' : ''}supabase snippets download ${id} |
@@ -447,6 +472,37 @@ export function assembleCompletionDiff(
}
}
/**
* Builds the request body sent to the AI completion endpoint. `options` is
* the caller-provided extra fields (e.g. `completionMetadata`), merged in
* last so it can override the defaults if it ever needs to.
*/
export function buildCompletionRequestBody({
projectRef,
connectionString,
orgSlug,
options,
}: {
projectRef: string | undefined
connectionString: string | undefined | null
orgSlug: string | undefined
options?: { completionMetadata?: unknown }
}): {
projectRef: string | undefined
connectionString: string | undefined | null
language: 'sql'
orgSlug: string | undefined
completionMetadata?: unknown
} {
return {
projectRef,
connectionString,
language: 'sql',
orgSlug,
...(options ?? {}),
}
}
/**
* Builds the prompt text used to ask the assistant to debug a failing snippet.
*/
@@ -454,3 +510,98 @@ export function buildDebugPromptText(sql: string, errorMessage: string): string
const prompt = `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}`
return `${prompt}\n\nSQL Query:\n\`\`\`sql\n${sql}\n\`\`\``
}
type DebugSnippet = { snippet: { content?: { unchecked_sql?: UntrustedSqlFragment } } } | undefined
type DebugResult = { error?: { message?: string } } | undefined
/**
* Extracts the SQL (disclaimer stripped) and error message used to debug a
* failing snippet, shared by `buildDebugPrompt` and `onDebug`. Falls back to
* an empty query / `'Unknown error'` rather than throwing when the snippet or
* its last result aren't available yet.
*/
export function extractDebugContext(
snippet: DebugSnippet,
result: DebugResult
): { sql: string; errorMessage: string } {
const sql = (snippet?.snippet.content?.unchecked_sql ?? '')
.replace(sqlAiDisclaimerComment, '')
.trim()
const errorMessage = result?.error?.message ?? 'Unknown error'
return { sql, errorMessage }
}
/**
* Builds the `aiSnap.newChat(...)` payload for the debug-this-snippet flow.
*/
export function buildDebugChatArgs(
snippet: DebugSnippet,
result: DebugResult
): { name: string; sqlSnippets: string[]; initialInput: string } {
const { sql, errorMessage } = extractDebugContext(snippet, result)
return {
name: 'Debug SQL snippet',
sqlSnippets: [sql],
initialInput: `Help me to debug the attached sql snippet which gives the following error: \n\n${errorMessage}`,
}
}
/** What `drainDiffRequest` should do with a pending diff request, given the editor's current value. */
export type DiffRequestPlan =
| { kind: 'replace'; text: string }
| { kind: 'diff'; diff: ContentDiff; diffType: DiffType }
/**
* Decides how to apply a pending diff request to the editor: if the editor is
* empty, just copy the request's SQL straight in; otherwise open a diff
* between what's there and the requested SQL. Pure decision only — the
* effect (`drainDiffRequest`) is left to actually touch the editor/diff state.
*/
export function planDiffRequestApplication({
existingValue,
request,
}: {
existingValue: string
request: { diffType: DiffType; sql: string }
}): DiffRequestPlan {
if (existingValue.length === 0) {
return { kind: 'replace', text: request.sql }
}
return {
kind: 'diff',
diff: { original: existingValue, modified: request.sql },
diffType: request.diffType,
}
}
/** What the window keydown handler should do for a key event, given the diff/prompt state. */
export type DiffKeyAction =
| { type: 'accept' }
| { type: 'escape'; shouldDiscard: boolean }
| { type: 'none' }
/**
* Decides how the SQL editor's window-level keydown handler should react:
* accept an open diff on Cmd/Ctrl+Enter, or discard-and-dismiss on Escape.
* No-ops when neither a diff nor the AI prompt is open, or for any other key.
*/
export function resolveDiffKeyAction(
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey'>,
{
isDiffOpen,
isPromptOpen,
os,
}: { isDiffOpen: boolean; isPromptOpen: boolean; os: 'macos' | 'windows' | undefined }
): DiffKeyAction {
if (!isDiffOpen && !isPromptOpen) return { type: 'none' }
switch (e.key) {
case 'Enter':
if ((os === 'macos' ? e.metaKey : e.ctrlKey) && isDiffOpen) return { type: 'accept' }
return { type: 'none' }
case 'Escape':
return { type: 'escape', shouldDiscard: isDiffOpen }
default:
return { type: 'none' }
}
}
@@ -2,6 +2,7 @@ import { useParams } from 'common'
import { useMemo } from 'react'
import { generateSnippetTitle } from './SQLEditor.constants'
import { deriveSnippetIdentity } from './SQLEditor.utils'
import { generateUuid } from '@/lib/api/snippets.browser'
import { useSqlEditorV2StateSnapshot } from '@/state/sql-editor/sql-editor-state'
@@ -23,13 +24,11 @@ export function useSnippetIdentity() {
return [name, generateUuid([`${name}.sql`])]
}, [urlId])
// the id is stable across renders - it depends either on the url or on the memoized generated id
const id = !urlId || urlId === 'new' ? generatedId : urlId
const snippetIsLoading = !(
id in snapV2.snippets && snapV2.snippets[id].snippet.content !== undefined
)
const isLoading = urlId === 'new' ? false : snippetIsLoading
const { id, isLoading } = deriveSnippetIdentity({
urlId,
generatedId,
snippets: snapV2.snippets,
})
return { id, urlId, generatedNewSnippetName, isLoading }
}
@@ -4,12 +4,15 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useState } from 'react
import { toast } from 'sonner'
import type { useSqlEditorDiff, useSqlEditorPrompt } from './hooks'
import { sqlAiDisclaimerComment } from './SQLEditor.constants'
import { DiffType, type IStandaloneDiffEditor } from './SQLEditor.types'
import {
assembleCompletionDiff,
buildCompletionRequestBody,
buildDebugChatArgs,
buildDebugPromptText,
createSqlSnippetSkeletonV2,
extractDebugContext,
planDiffRequestApplication,
} from './SQLEditor.utils'
import { useSQLEditorContext } from './SQLEditorContext'
import { useSnippetTitleGenerator } from './useSnippetTitleGenerator'
@@ -100,10 +103,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
const buildDebugPrompt = useCallback(() => {
const snippet = snapV2.snippets[id]
const result = sessionSnap.results[id]?.[0]
const sql = (snippet?.snippet.content?.unchecked_sql ?? '')
.replace(sqlAiDisclaimerComment, '')
.trim()
const errorMessage = result?.error?.message ?? 'Unknown error'
const { sql, errorMessage } = extractDebugContext(snippet, result)
return buildDebugPromptText(sql, errorMessage)
}, [id, sessionSnap.results, snapV2.snippets])
@@ -113,13 +113,7 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
const snippet = snapV2.snippets[id]
const result = sessionSnap.results[id]?.[0]
openSidebar(SIDEBAR_KEYS.AI_ASSISTANT)
aiSnap.newChat({
name: 'Debug SQL snippet',
sqlSnippets: [
(snippet.snippet.content?.unchecked_sql ?? '').replace(sqlAiDisclaimerComment, '').trim(),
],
initialInput: `Help me to debug the attached sql snippet which gives the following error: \n\n${result.error.message}`,
})
aiSnap.newChat(buildDebugChatArgs(snippet, result))
} catch (error: unknown) {
// [Joshen] There's a tendency for the SQL debug to chuck a lengthy error message
// that's not relevant for the user - so we prettify it here by avoiding to return the
@@ -206,13 +200,14 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
'Content-Type': 'application/json',
...(options?.headers ?? {}),
},
body: JSON.stringify({
projectRef: project?.ref,
connectionString: project?.connectionString,
language: 'sql',
orgSlug: org?.slug,
...(options?.body ?? {}),
}),
body: JSON.stringify(
buildCompletionRequestBody({
projectRef: project?.ref,
connectionString: project?.connectionString,
orgSlug: org?.slug,
options: options?.body,
})
),
})
if (!response.ok) {
@@ -333,21 +328,19 @@ export function useSqlEditorAi({ id, editorMountCount, diff, prompt }: UseSqlEdi
// on mount and re-runs this effect, so the request applies once mounted.
if (!editorModel) return
const { diffType, sql } = request
const existingValue = editorRef.current?.getValue() ?? ''
if (existingValue.length === 0) {
const plan = planDiffRequestApplication({ existingValue, request })
if (plan.kind === 'replace') {
// if the editor is empty, just copy over the code
editorRef.current?.executeEdits('apply-ai-message', [
{
text: `${sql}`,
text: plan.text,
range: editorModel.getFullModelRange(),
},
])
} else {
const currentSql = editorRef.current?.getValue()
const diff = { original: currentSql || '', modified: sql }
setSourceSqlDiff(diff)
setSelectedDiffType(diffType)
setSourceSqlDiff(plan.diff)
setSelectedDiffType(plan.diffType)
}
// One-shot: drain the request so it can't re-apply to a later editor or session.
@@ -2,6 +2,7 @@ import { useParams } from 'common'
import { useRouter } from 'next/router'
import { useCallback, useEffect } from 'react'
import { resolveDiffKeyAction } from './SQLEditor.utils'
import { useSQLEditorContext } from './SQLEditorContext'
import { detectOS } from '@/lib/helpers'
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
@@ -56,20 +57,20 @@ export function useSqlEditorShortcuts({
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (!isDiffOpen && !isPromptOpen) return
const action = resolveDiffKeyAction(e, { isDiffOpen, isPromptOpen, os })
switch (e.key) {
case 'Enter':
if ((os === 'macos' ? e.metaKey : e.ctrlKey) && isDiffOpen) {
acceptAiHandler()
resetPrompt()
}
switch (action.type) {
case 'accept':
acceptAiHandler()
resetPrompt()
return
case 'Escape':
if (isDiffOpen) discardAiHandler()
case 'escape':
if (action.shouldDiscard) discardAiHandler()
resetPrompt()
editorRef.current?.focus()
return
case 'none':
return
}
}
window.addEventListener('keydown', handler)