Files
supabase/apps/studio/data/logs/logs-sql-rewrite.test.ts
Charis 8b38e0d1ed feat(studio): ClickHouse dialect for logs snippet AI + rewrite to ClickHouse (#48501)
## 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 -->
2026-08-04 09:02:40 -04:00

163 lines
5.7 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import {
detectLogSource,
looksLikeLegacyLogsQuery,
rewriteLogsSqlWithAI,
shouldOfferLegacyLogsRewrite,
stripSqlCodeFences,
} from './logs-sql-rewrite'
describe('shouldOfferLegacyLogsRewrite', () => {
const legacySql = 'select 1 from edge_logs cross join unnest(metadata) as m'
it('offers the rewrite for BigQuery-dialect SQL once logs run on ClickHouse', () => {
expect(shouldOfferLegacyLogsRewrite({ sql: legacySql, isClickhouseLogsEnabled: true })).toBe(
true
)
})
it('never offers it on a non-migrated org, where the BigQuery SQL is still correct', () => {
expect(shouldOfferLegacyLogsRewrite({ sql: legacySql, isClickhouseLogsEnabled: false })).toBe(
false
)
})
it('does not offer it for SQL that is already ClickHouse, or for empty SQL', () => {
expect(
shouldOfferLegacyLogsRewrite({
sql: "select timestamp from logs where source = 'edge_logs' limit 5",
isClickhouseLogsEnabled: true,
})
).toBe(false)
expect(shouldOfferLegacyLogsRewrite({ sql: '', isClickhouseLogsEnabled: true })).toBe(false)
})
})
describe('detectLogSource', () => {
it('reads an explicit source filter', () => {
expect(detectLogSource("select 1 from logs where source = 'edge_logs'")).toBe('edge_logs')
})
it('falls back to the legacy FROM table name', () => {
expect(detectLogSource('select 1 from edge_logs as t')).toBe('edge_logs')
})
it('maps pg_cron_logs to postgres_logs from either the FROM table or the source filter', () => {
expect(detectLogSource('select 1 from pg_cron_logs')).toBe('postgres_logs')
expect(detectLogSource("select 1 from logs where source = 'pg_cron_logs'")).toBe(
'postgres_logs'
)
})
it('returns undefined for the bare logs table with no source', () => {
expect(detectLogSource('select 1 from logs limit 5')).toBeUndefined()
})
it('returns undefined when nothing matches', () => {
expect(detectLogSource('select 1')).toBeUndefined()
})
it('ignores a column that merely ends in "source"', () => {
expect(detectLogSource("select 1 from logs where resource = 'nope'")).toBeUndefined()
expect(detectLogSource("select 1 from logs where datasource = 'nope'")).toBeUndefined()
})
it('still reads a qualified source column', () => {
expect(detectLogSource("select 1 from logs t where t.source = 'auth_logs'")).toBe('auth_logs')
})
it('prefers the real source column over a lookalike earlier in the query', () => {
expect(detectLogSource("select resource = 'nope' from logs where source = 'edge_logs'")).toBe(
'edge_logs'
)
})
})
describe('looksLikeLegacyLogsQuery', () => {
it('flags per-service FROM tables', () => {
expect(looksLikeLegacyLogsQuery('select 1 from edge_logs')).toBe(true)
})
it('flags unnest joins and the cast-timestamp idiom', () => {
expect(looksLikeLegacyLogsQuery('select 1 from logs cross join unnest(metadata) as m')).toBe(
true
)
expect(looksLikeLegacyLogsQuery('select cast(timestamp as datetime) from logs')).toBe(true)
})
it('does not flag a ClickHouse query against the logs table', () => {
expect(
looksLikeLegacyLogsQuery("select timestamp from logs where source = 'edge_logs' limit 5")
).toBe(false)
})
})
describe('stripSqlCodeFences', () => {
it('removes a ```sql fenced block', () => {
expect(stripSqlCodeFences('```sql\nselect 1 from logs\n```')).toBe('select 1 from logs')
})
it('removes a plain ``` fenced block', () => {
expect(stripSqlCodeFences('```\nselect 1\n```')).toBe('select 1')
})
it('leaves unfenced SQL untouched (trimmed)', () => {
expect(stripSqlCodeFences(' select 1 from logs ')).toBe('select 1 from logs')
})
it('extracts the fenced block when wrapped in prose', () => {
expect(stripSqlCodeFences('Here is the rewrite:\n```sql\nselect 1 from logs\n```')).toBe(
'select 1 from logs'
)
})
})
describe('rewriteLogsSqlWithAI', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('declares the rewrite intent and sends the query as the selection', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => '```sql\nselect 1 from logs\n```',
})
vi.stubGlobal('fetch', fetchMock)
const result = await rewriteLogsSqlWithAI({
sql: 'select 1 from edge_logs',
projectRef: 'abc',
availableKeys: ['request.method'],
})
expect(result).toBe('select 1 from logs')
const [url, init] = fetchMock.mock.calls[0]
expect(url).toContain('/api/ai/code/complete')
const body = JSON.parse(init.body)
expect(body.dialect).toBe('clickhouse')
expect(body.intent).toBe('rewrite')
// The whole query is the selection, so the rewrite replaces all of it.
expect(body.completionMetadata.selection).toBe('select 1 from edge_logs')
expect(body.completionMetadata.textBeforeCursor).toBe('')
expect(body.completionMetadata.textAfterCursor).toBe('')
expect(body.completionMetadata.availableKeys).toEqual(['request.method'])
// No prompt text is carried client-side — the route owns the instruction.
expect(body.completionMetadata.prompt).toBe('')
})
it('throws when the request fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => 'boom' }))
await expect(rewriteLogsSqlWithAI({ sql: 'select 1', projectRef: 'abc' })).rejects.toThrow(
'boom'
)
})
it('throws when the model returns an empty query', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ' ' }))
await expect(rewriteLogsSqlWithAI({ sql: 'select 1', projectRef: 'abc' })).rejects.toThrow(
'empty'
)
})
})