diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts index 56d728c654f..9c6865b0c79 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.test.ts @@ -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> = {}) => ({ + 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;') diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts index ab35c5a25ae..2c47067791b 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.utils.ts @@ -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 +}): { 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, + { + 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' } + } +} diff --git a/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.ts b/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.ts index 9d4f303bf03..30d8a57ad00 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSnippetIdentity.ts @@ -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 } } diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts index ca0fec82805..cc457c1bf5e 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorAi.ts @@ -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. diff --git a/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts b/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts index d5c95518c8b..d0996726b10 100644 --- a/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts +++ b/apps/studio/components/interfaces/SQLEditor/useSqlEditorShortcuts.ts @@ -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)