mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +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, plus a refactor of the shared logs-rewrite flow. PR 8 of the SQL editor query-source series. Stacked on #48457 — review that one first, and merge this after it. ## What is the current behavior? A `log_sql` snippet runs against the ClickHouse-backed analytics endpoint, but the SQL editor's AI still writes Postgres: inline edits get Postgres system prompts, and the result is run through `sql-formatter`, which mangles ClickHouse backticks and `log_attributes` map lookups. Legacy Logs Explorer saved queries open in the editor as `log_sql` snippets. Those are BigQuery dialect and error against the ClickHouse endpoint the editor runs them on, with no in-editor way out — only the Logs Explorer offered a rewrite. The completion route was also asymmetric. It assembled a schema/code/instruction message for Postgres but forwarded `prompt` verbatim for ClickHouse, so a client wanting ClickHouse had to hand-build the equivalent string. ## What is the new behavior? **Inline AI speaks ClickHouse for logs snippets.** `sqlSourceToDialect` maps a snippet's source to `postgres`/`clickhouse` and `buildCompletionRequestBody` threads it through. For ClickHouse, `useSqlEditorAi` strips code fences from the response and skips `formatSql`. Execution and dialect both follow the snippet type, so a snippet's valid dialect never flips. **Rewrite to ClickHouse in the editor.** A banner offers the rewrite for a logs snippet whose text trips `looksLikeLegacyLogsQuery`, and proposes the result through the editor's existing AI diff view rather than replacing the snippet, so it's accepted or discarded like any other AI edit. Gated on `otelLegacyLogs`: on a non-migrated org the BigQuery text is still correct, so rewriting it would break a working query. The offer is a state machine (`offered` / `rewriting` / `failed` / `noRewriteNeeded` / `dismissed`) with a declarative table of valid transitions, so the states are mutually exclusive by construction and dismissal is terminal. A failure keeps its message and offers a retry; a response identical to the input is reported rather than opening an empty diff. **One place assembles completion prompts.** The route now uses a single template for both dialects, branching only the schema section and — for `intent: 'rewrite'` — the instruction. `lib/ai/clickhouse-logs.ts` is the single home for ClickHouse-logs prompt content, replacing two independently maintained descriptions of the same table. Clients carry no prompt text. **The rewrite flow is shared with the Logs Explorer.** Both surfaces previously hand-rolled the same sequence and had drifted: only one detected a no-op rewrite, they sourced `log_attributes` keys differently, and the Explorer formatted errors with an `as Error` cast. Both now use `useLegacyLogsRewrite` and the same state-driven banner, so the Explorer picks up no-op detection and typed error extraction. **Attribute keys are fetched on submit, not while typing.** The detected source would otherwise feed a reactive query key, making every edit that changed it cost another network call. `useLogsAttributeKeys` is imperative and goes through `queryClient.fetchQuery`, so a source already cached — including by the Explorer header and query panel, which subscribe reactively — is reused. This also closes a gap where inline edits never received keys at all, unlike full rewrites. `getErrorMessage` gains an optional typed fallback and no longer stringifies a bare object into `'[object Object]'`; every existing caller already hand-rolled a fallback, except `QueueSettings`, which interpolated the raw result and now passes one. Nothing here is user-visible until the `sqlEditorLogsSource` flag is enabled. Tests: dialect selection and request-body shape, the ClickHouse prompt content (including that the schema section does not restate the dialect rules), the reducer's valid and invalid transitions, `shouldOfferLegacyLogsRewrite`, on-submit key discovery with cache reuse, and `getErrorMessage`. ## Additional context <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added an Assistant banner to help rewrite legacy BigQuery-style logs queries into ClickHouse SQL. * SQL assistance now adapts to the selected query type, including relevant log attribute context. * Rewrite suggestions can be reviewed as editor diffs before being applied. * **Bug Fixes** * Improved rewrite failure handling, retry options, dismissal behavior, and “no rewrite needed” messaging. * Error notifications now provide a clearer fallback message when details are unavailable. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
125 lines
4.1 KiB
TypeScript
125 lines
4.1 KiB
TypeScript
import { act, waitFor } from '@testing-library/react'
|
|
import { HttpResponse } from 'msw'
|
|
import { beforeEach, describe, expect, it } from 'vitest'
|
|
|
|
import { useLogsAttributeKeys } from './useLogsAttributeKeys'
|
|
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
|
import { addAPIMock } from '@/tests/lib/msw'
|
|
import { renderSqlEditorHook, setupSqlEditorMocks } from '@/tests/lib/sql-editor-test-utils'
|
|
|
|
const OTEL_ENDPOINT = '/platform/projects/:ref/analytics/endpoints/logs.all.otel'
|
|
|
|
const queryFor = (source: string) => `select 1 from logs where source = '${source}'`
|
|
|
|
/** Records the SQL of every key-discovery request so we can count them. */
|
|
function mockKeyDiscovery({ fails = false }: { fails?: boolean } = {}) {
|
|
const requests: string[] = []
|
|
addAPIMock({
|
|
method: 'post',
|
|
path: OTEL_ENDPOINT,
|
|
response: async ({ request }) => {
|
|
const body = (await request.clone().json()) as { sql: string }
|
|
requests.push(body.sql)
|
|
if (fails) return HttpResponse.json({ message: 'boom' }, { status: 500 })
|
|
return HttpResponse.json({ result: [{ key: 'request.method' }] })
|
|
},
|
|
})
|
|
return requests
|
|
}
|
|
|
|
/**
|
|
* Exposes the resolved project alongside the hook. Discovery needs a project ref,
|
|
* so tests must wait for that query before asking — otherwise a lookup no-ops and
|
|
* the request counts below would pass for the wrong reason.
|
|
*/
|
|
function useKeysHarness() {
|
|
const { data: project } = useSelectedProjectQuery()
|
|
const { fetchAttributeKeys } = useLogsAttributeKeys()
|
|
return { projectRef: project?.ref, fetchAttributeKeys }
|
|
}
|
|
|
|
async function renderReadyHarness() {
|
|
const utils = renderSqlEditorHook(useKeysHarness)
|
|
await waitFor(() => expect(utils.result.current.projectRef).toBe('default'))
|
|
return utils
|
|
}
|
|
|
|
beforeEach(() => {
|
|
setupSqlEditorMocks()
|
|
})
|
|
|
|
describe('useLogsAttributeKeys', () => {
|
|
it('makes no request until asked', async () => {
|
|
const requests = mockKeyDiscovery()
|
|
|
|
await renderReadyHarness()
|
|
|
|
expect(requests).toHaveLength(0)
|
|
})
|
|
|
|
it('returns the discovered keys for the query source when asked', async () => {
|
|
const requests = mockKeyDiscovery()
|
|
const { result } = await renderReadyHarness()
|
|
|
|
let keys: string[] | undefined
|
|
await act(async () => {
|
|
keys = await result.current.fetchAttributeKeys(queryFor('edge_logs'))
|
|
})
|
|
|
|
expect(keys).toEqual(['request.method'])
|
|
expect(requests).toHaveLength(1)
|
|
expect(requests[0]).toContain("source = 'edge_logs'")
|
|
})
|
|
|
|
it('reuses the cached result for a source already looked up', async () => {
|
|
const requests = mockKeyDiscovery()
|
|
const { result } = await renderReadyHarness()
|
|
|
|
await act(async () => {
|
|
await result.current.fetchAttributeKeys(queryFor('edge_logs'))
|
|
await result.current.fetchAttributeKeys(queryFor('edge_logs'))
|
|
})
|
|
|
|
expect(requests).toHaveLength(1)
|
|
})
|
|
|
|
it('looks up a different source separately', async () => {
|
|
const requests = mockKeyDiscovery()
|
|
const { result } = await renderReadyHarness()
|
|
|
|
await act(async () => {
|
|
await result.current.fetchAttributeKeys(queryFor('edge_logs'))
|
|
await result.current.fetchAttributeKeys(queryFor('postgres_logs'))
|
|
})
|
|
|
|
expect(requests).toHaveLength(2)
|
|
expect(requests[1]).toContain("source = 'postgres_logs'")
|
|
})
|
|
|
|
it('resolves undefined without a request when no source is detectable', async () => {
|
|
const requests = mockKeyDiscovery()
|
|
const { result } = await renderReadyHarness()
|
|
|
|
let keys: string[] | undefined
|
|
await act(async () => {
|
|
keys = await result.current.fetchAttributeKeys('select 1 from logs limit 5')
|
|
})
|
|
|
|
expect(keys).toBeUndefined()
|
|
expect(requests).toHaveLength(0)
|
|
})
|
|
|
|
it('resolves undefined rather than throwing when discovery fails', async () => {
|
|
mockKeyDiscovery({ fails: true })
|
|
const { result } = await renderReadyHarness()
|
|
|
|
let keys: string[] | undefined
|
|
await act(async () => {
|
|
keys = await result.current.fetchAttributeKeys(queryFor('edge_logs'))
|
|
})
|
|
|
|
// Keys are an enhancement — a failed lookup must not block the caller.
|
|
expect(keys).toBeUndefined()
|
|
})
|
|
})
|