mirror of
https://github.com/supabase/supabase.git
synced 2026-09-07 02:20:52 +08:00
## 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? Feature (SQL editor: execution wiring for logs-source snippets). Part of the stacked SQL-editor "Database vs Logs" query-source series. ## What is the current behavior? The SQL editor only ever runs queries against the user's Postgres database. There is no execution path for a logs (`log_sql`) snippet, and the run-button telemetry event carries no backend discriminator. ## What is the new behavior? - `useRunSource(id)` derives the run backend from the snippet type; a `log_sql` snippet resolves to `{ type: 'logs', dateRange }`, pairing the run with its session time range (default: last hour). - `useLogsSqlExecution` runs a promoted `SafeLogSqlFragment` against the analytics OTEL (ClickHouse) endpoint with the resolved time range as `iso_timestamp_start`/`iso_timestamp_end` request params. The endpoint is **pinned to OTEL** — a snippet's dialect must not flip with org migration. - The run gestures (toolbar button and Cmd+Enter) branch on the source and promote with the matching `acceptUntrusted*` right at the user action, preserving the auditable promotion-at-gesture boundary. pg intellisense is gated off for logs snippets. - The `sql_editor_query_run_button_clicked` telemetry event gains a required `{ source: 'database' | 'logs' }` property, fired from both execution paths. - Capability guard: a `log_sql` snippet is reachable by direct URL regardless of the (later) entry-point flag gating, so `executeLogsQuery` short-circuits when `otelLegacyLogs` is off — recording a clear "not available yet" result message instead of firing a request that would only return an opaque backend error on a non-ClickHouse project. This is a guard on the gesture, not endpoint selection. - Tests: `useRunSource` routing, `useLogsSqlExecution` endpoint/range/structured-error/capability-guard, and a reusable `flags` option on `renderSqlEditorHook`. No UI entry points are added — the feature runs dark until the flag-gated creation/nav PRs later in the stack. ## Additional context Stacked on the query-source series; base branch is `master` now that PR 4 (log date range domain + session state, #48401) is merged. Follow-ups in the stack add the toolbar/creation UI (with a run-affordance gate on `otelLegacyLogs`), nav section, AI dialect support, and reports guard. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for running log queries directly from the SQL editor. * Log query results, errors, and time ranges are now handled within the editor session. * Added automatic selection between database and log query execution, including support for custom date ranges. * SQL assistance is disabled while editing log queries where database definitions do not apply. * **Tests** * Added coverage for log query execution, date ranges, feature availability, and execution source selection. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
import { untrustedSql } from '@supabase/pg-meta'
|
|
import type { QueryClient } from '@tanstack/react-query'
|
|
import { renderHook, type RenderHookOptions } from '@testing-library/react'
|
|
import { FeatureFlagContext } from 'common'
|
|
import { http, HttpResponse } from 'msw'
|
|
import { NuqsTestingAdapter } from 'nuqs/adapters/testing'
|
|
import type { ReactNode } from 'react'
|
|
|
|
import type { SqlSnippetSource } from '@/components/interfaces/SQLEditor/querySource'
|
|
import type {
|
|
ContentDiff,
|
|
DiffController,
|
|
EditorController,
|
|
} from '@/components/interfaces/SQLEditor/SQLEditor.types'
|
|
import {
|
|
computeErrorHighlightLine,
|
|
createSqlSnippetSkeletonV2,
|
|
} from '@/components/interfaces/SQLEditor/SQLEditor.utils'
|
|
import { SQLEditorProvider } from '@/components/interfaces/SQLEditor/SQLEditorContext'
|
|
import { API_URL, OPT_IN_TAGS } from '@/lib/constants'
|
|
import type { ProfileContextType } from '@/lib/profile'
|
|
import { AiAssistantStateContext, createAiAssistantState } from '@/state/ai-assistant-state'
|
|
import {
|
|
createDatabaseSelectorState,
|
|
DatabaseSelectorStateContext,
|
|
} from '@/state/database-selector'
|
|
import {
|
|
createRoleImpersonationState,
|
|
RoleImpersonationStateContext,
|
|
} from '@/state/role-impersonation-state'
|
|
import { sidebarManagerState } from '@/state/sidebar-manager-state'
|
|
import { sqlEditorDiffRequestState } from '@/state/sql-editor/sql-editor-diff-request'
|
|
import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state'
|
|
import { sqlEditorState } from '@/state/sql-editor/sql-editor-state'
|
|
import { CustomWrapper } from '@/tests/lib/custom-render'
|
|
import { addAPIMock, mswServer } from '@/tests/lib/msw'
|
|
|
|
export type InMemoryEditor = {
|
|
editor: EditorController
|
|
diff: DiffController
|
|
setValue: (value: string) => void
|
|
setSelection: (value: string | undefined, startLineNumber?: number) => void
|
|
getHighlightedLine: () => number | undefined
|
|
getRevealedLine: () => number | undefined
|
|
getDiffOriginal: () => string
|
|
getDiffModified: () => string
|
|
getDiffRevealedLine: () => number | undefined
|
|
setDiffMounted: (mounted: boolean) => void
|
|
}
|
|
|
|
export function createInMemoryEditor(initialValue: string = ''): InMemoryEditor {
|
|
let value = initialValue
|
|
let selectionValue: string | undefined
|
|
let selectionStartLine: number | undefined
|
|
let highlightedLine: number | undefined
|
|
let revealedLine: number | undefined
|
|
|
|
let diffMounted = true
|
|
let diffOriginal = ''
|
|
let diffModified = ''
|
|
let diffRevealedLine: number | undefined
|
|
|
|
const editor: EditorController = {
|
|
isReady: () => true,
|
|
getValue: () => value,
|
|
getSelectionStartLine: () => selectionStartLine,
|
|
getSql: (snippetContent) => untrustedSql(selectionValue || value || snippetContent || ''),
|
|
replaceAll: (text) => {
|
|
value = text
|
|
},
|
|
focus: () => {},
|
|
revealLineInCenter: (line) => {
|
|
revealedLine = line
|
|
},
|
|
highlightErrorLine: (error, hasSelection) => {
|
|
const startLineNumber = hasSelection ? (selectionStartLine ?? 0) : 0
|
|
const line = computeErrorHighlightLine(error, startLineNumber)
|
|
if (Number.isNaN(line)) return
|
|
highlightedLine = line
|
|
revealedLine = line
|
|
},
|
|
clearHighlights: () => {
|
|
highlightedLine = undefined
|
|
},
|
|
}
|
|
|
|
const diff: DiffController = {
|
|
isMounted: () => diffMounted,
|
|
getModifiedValue: () => diffModified,
|
|
setDiff: (contentDiff: ContentDiff, revealLine: number) => {
|
|
diffOriginal = contentDiff.original
|
|
diffModified = contentDiff.modified
|
|
diffRevealedLine = revealLine
|
|
},
|
|
attach: () => {
|
|
diffMounted = true
|
|
},
|
|
}
|
|
|
|
return {
|
|
editor,
|
|
diff,
|
|
setValue: (v) => {
|
|
value = v
|
|
},
|
|
setSelection: (v, startLine = 1) => {
|
|
selectionValue = v
|
|
selectionStartLine = v === undefined ? undefined : startLine
|
|
},
|
|
getHighlightedLine: () => highlightedLine,
|
|
getRevealedLine: () => revealedLine,
|
|
getDiffOriginal: () => diffOriginal,
|
|
getDiffModified: () => diffModified,
|
|
getDiffRevealedLine: () => diffRevealedLine,
|
|
setDiffMounted: (mounted) => {
|
|
diffMounted = mounted
|
|
},
|
|
}
|
|
}
|
|
|
|
/** Call in `beforeEach` for full isolation between SQL editor hook tests. */
|
|
export function resetSqlEditorStores() {
|
|
for (const key of Object.keys(sqlEditorState.snippets)) {
|
|
delete sqlEditorState.snippets[key]
|
|
}
|
|
for (const key of Object.keys(sqlEditorState.folders)) {
|
|
delete sqlEditorState.folders[key]
|
|
}
|
|
sqlEditorState.needsSaving.clear()
|
|
sqlEditorState.pendingFolderSaves.clear()
|
|
|
|
for (const key of Object.keys(sqlEditorSessionState.results)) {
|
|
delete sqlEditorSessionState.results[key]
|
|
}
|
|
sqlEditorSessionState.limit = 100
|
|
|
|
sqlEditorDiffRequestState.pending = undefined
|
|
|
|
sidebarManagerState.sidebars = {}
|
|
sidebarManagerState.activeSidebar = undefined
|
|
sidebarManagerState.pendingSidebarOpen = undefined
|
|
sidebarManagerState.isMaximised = false
|
|
}
|
|
|
|
export function seedSnippet({
|
|
id,
|
|
projectRef = 'default',
|
|
name = 'Test query',
|
|
sql = '',
|
|
ownerId = 1,
|
|
projectId = 1,
|
|
source = 'database',
|
|
}: {
|
|
id: string
|
|
projectRef?: string
|
|
name?: string
|
|
sql?: string
|
|
ownerId?: number
|
|
projectId?: number
|
|
source?: SqlSnippetSource
|
|
}) {
|
|
const snippet = createSqlSnippetSkeletonV2({
|
|
name,
|
|
sql,
|
|
owner_id: ownerId,
|
|
project_id: projectId,
|
|
idOverride: id,
|
|
source,
|
|
})
|
|
sqlEditorState.addSnippet({ projectRef, snippet })
|
|
return snippet
|
|
}
|
|
|
|
export function setupSqlEditorMocks({
|
|
ref = 'default',
|
|
connectionString = 'postgresql://postgres@localhost:5432/postgres',
|
|
orgId = 1,
|
|
orgSlug = 'test-org',
|
|
optInTags = [OPT_IN_TAGS.AI_SQL],
|
|
queryRows = [] as unknown[],
|
|
}: {
|
|
ref?: string
|
|
connectionString?: string
|
|
orgId?: number
|
|
orgSlug?: string
|
|
optInTags?: string[]
|
|
queryRows?: unknown[]
|
|
} = {}) {
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/projects/:ref',
|
|
response: {
|
|
id: 1,
|
|
ref,
|
|
organization_id: orgId,
|
|
name: 'Test Project',
|
|
status: 'ACTIVE_HEALTHY',
|
|
cloud_provider: 'AWS',
|
|
region: 'us-east-1',
|
|
db_host: `db.${ref}.supabase.co`,
|
|
restUrl: `https://${ref}.supabase.co/rest/v1/`,
|
|
inserted_at: '2024-01-01T00:00:00Z',
|
|
updated_at: '2024-01-01T00:00:00Z',
|
|
subscription_id: 'sub_123',
|
|
is_branch_enabled: false,
|
|
is_physical_backups_enabled: false,
|
|
high_availability: false,
|
|
integration_source: null,
|
|
connectionString,
|
|
is_hibernating: false,
|
|
},
|
|
})
|
|
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/organizations',
|
|
response: [
|
|
{
|
|
id: orgId,
|
|
slug: orgSlug,
|
|
name: 'Test Org',
|
|
billing_email: 'test@supabase.io',
|
|
billing_partner: null,
|
|
integration_source: null,
|
|
is_owner: true,
|
|
opt_in_tags: optInTags,
|
|
organization_missing_address: false,
|
|
organization_missing_tax_id: false,
|
|
organization_requires_mfa: false,
|
|
plan: { id: 'free', name: 'Free' },
|
|
restriction_data: null,
|
|
restriction_status: null,
|
|
stripe_customer_id: null,
|
|
subscription_id: null,
|
|
usage_billing_enabled: false,
|
|
},
|
|
],
|
|
})
|
|
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/organizations/:slug/billing/subscription',
|
|
response: {
|
|
addons: [],
|
|
billing_cycle_anchor: 1700000000,
|
|
billing_via_partner: false,
|
|
current_period_end: 1700000000,
|
|
current_period_start: 1700000000,
|
|
next_invoice_at: 1700000000,
|
|
payment_method_type: 'card',
|
|
plan: { id: 'free', name: 'Free' },
|
|
project_addons: [],
|
|
scheduled_plan_change: null,
|
|
usage_billing_enabled: false,
|
|
},
|
|
})
|
|
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/projects/:ref/settings',
|
|
response: {
|
|
cloud_provider: 'AWS',
|
|
db_dns_name: `db.${ref}.supabase.co`,
|
|
db_host: `db.${ref}.supabase.co`,
|
|
db_ip_addr_config: 'ipv4',
|
|
db_name: 'postgres',
|
|
db_port: 5432,
|
|
db_user: 'postgres',
|
|
inserted_at: '2024-01-01T00:00:00Z',
|
|
name: 'Test Project',
|
|
ref,
|
|
region: 'us-east-1',
|
|
ssl_enforced: false,
|
|
status: 'ACTIVE_HEALTHY',
|
|
},
|
|
})
|
|
|
|
addAPIMock({
|
|
method: 'get',
|
|
path: '/platform/projects/:ref/databases',
|
|
response: [
|
|
{
|
|
identifier: ref,
|
|
connectionString,
|
|
cloud_provider: 'AWS',
|
|
db_host: `db.${ref}.supabase.co`,
|
|
db_name: 'postgres',
|
|
db_port: 5432,
|
|
db_user: 'postgres',
|
|
inserted_at: '2024-01-01T00:00:00Z',
|
|
region: 'us-east-1',
|
|
restUrl: `https://${ref}.supabase.co/rest/v1/`,
|
|
size: 'ci_micro',
|
|
status: 'ACTIVE_HEALTHY',
|
|
},
|
|
],
|
|
})
|
|
|
|
addAPIMock({
|
|
method: 'post',
|
|
path: '/platform/pg-meta/:ref/query',
|
|
response: () => HttpResponse.json(queryRows),
|
|
})
|
|
|
|
// Internal Next API routes (not part of the platform OpenAPI spec), hit via
|
|
// raw fetch — same MSW server, just registered directly rather than through
|
|
// the typed `addAPIMock` helper.
|
|
mswServer.use(
|
|
http.post(`${API_URL}/ai/sql/title-v2`, async () =>
|
|
HttpResponse.json({ title: 'Generated title', description: '' })
|
|
),
|
|
http.post(`${API_URL}/ai/code/complete`, async () => HttpResponse.json('select 1;'))
|
|
)
|
|
}
|
|
|
|
type NuqsAdapterProps = Partial<Parameters<typeof NuqsTestingAdapter>[0]>
|
|
|
|
type RenderSqlEditorHookOptions<TProps> = {
|
|
initialProps?: TProps
|
|
queryClient?: QueryClient
|
|
nuqs?: NuqsAdapterProps
|
|
profileContext?: ProfileContextType
|
|
inMemoryEditor?: InMemoryEditor
|
|
aiAssistantState?: ReturnType<typeof createAiAssistantState>
|
|
databaseSelectorState?: ReturnType<typeof createDatabaseSelectorState>
|
|
roleImpersonationState?: ReturnType<typeof createRoleImpersonationState>
|
|
/** ConfigCat flags to expose via FeatureFlagContext (e.g. `{ otelLegacyLogs: true }`). */
|
|
flags?: Record<string, boolean>
|
|
}
|
|
|
|
export function renderSqlEditorHook<TResult, TProps = undefined>(
|
|
hook: (props: TProps) => TResult,
|
|
options?: RenderSqlEditorHookOptions<TProps>
|
|
) {
|
|
const inMemoryEditor = options?.inMemoryEditor ?? createInMemoryEditor()
|
|
const aiAssistantState = options?.aiAssistantState ?? createAiAssistantState()
|
|
const databaseSelectorState = options?.databaseSelectorState ?? createDatabaseSelectorState()
|
|
const roleImpersonationState =
|
|
options?.roleImpersonationState ??
|
|
createRoleImpersonationState('default', { current: async () => ({}) })
|
|
|
|
const flags = options?.flags
|
|
|
|
const wrapper = ({ children }: { children: ReactNode }) => {
|
|
const tree = (
|
|
<RoleImpersonationStateContext.Provider value={roleImpersonationState}>
|
|
<DatabaseSelectorStateContext.Provider value={databaseSelectorState}>
|
|
<AiAssistantStateContext.Provider value={aiAssistantState}>
|
|
<SQLEditorProvider editor={inMemoryEditor.editor} diff={inMemoryEditor.diff}>
|
|
{children}
|
|
</SQLEditorProvider>
|
|
</AiAssistantStateContext.Provider>
|
|
</DatabaseSelectorStateContext.Provider>
|
|
</RoleImpersonationStateContext.Provider>
|
|
)
|
|
|
|
return (
|
|
<CustomWrapper
|
|
queryClient={options?.queryClient}
|
|
nuqs={options?.nuqs}
|
|
profileContext={options?.profileContext}
|
|
>
|
|
{flags ? (
|
|
<FeatureFlagContext.Provider value={{ configcat: flags, posthog: {}, hasLoaded: true }}>
|
|
{tree}
|
|
</FeatureFlagContext.Provider>
|
|
) : (
|
|
tree
|
|
)}
|
|
</CustomWrapper>
|
|
)
|
|
}
|
|
|
|
const result = renderHook(hook, {
|
|
initialProps: options?.initialProps,
|
|
wrapper,
|
|
} as RenderHookOptions<TProps>)
|
|
|
|
return {
|
|
...result,
|
|
inMemoryEditor,
|
|
aiAssistantState,
|
|
databaseSelectorState,
|
|
roleImpersonationState,
|
|
}
|
|
}
|