mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 18:11:51 +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 -->
163 lines
5.7 KiB
TypeScript
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'
|
|
)
|
|
})
|
|
})
|