Files
supabase/apps/studio/data/logs/safe-analytics-sql.test.ts
Charis ec1c889349 feat(studio): logs SQL brands + execution data layer (#48301)
## 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 (data layer only — PR 1 of the SQL-editor query-source stack;
nothing user-visible yet, no consumers).

## What is the current behavior?

The Studio SQL editor only runs queries against Postgres. There is no
type-safe brand for user-authored logs SQL and no
execution/normalization layer for running SQL against the logs/analytics
(ClickHouse) backend.

## What is the new behavior?

Pure additions, no behavior change:

- `data/logs/safe-analytics-sql.ts` — adds distinct untrusted/safe
brands for user-authored logs SQL (`UntrustedLogSqlFragment`,
`untrustedLogSql`, `acceptUntrustedLogsSql`), mirroring pg-meta's
`UntrustedSqlFragment` but kept intentionally disjoint so Postgres and
logs SQL can never cross boundaries.
- `data/logs/execute-logs-sql-mutation.ts` (new) — `executeLogsSql`
wraps `executeAnalyticsSql`, attaches the resolved time range as request
params (`iso_timestamp_start/end`, never spliced into SQL), and
normalizes to `{ rows, error? }`; `mapLogsError` normalizes the
analytics backend's structured 200-body error into the `{ message }`
shape the result pane reads; `useExecuteLogsSqlMutation` collapses
transport and 200-body errors into React Query's single `onError` path.
- Unit tests for `mapLogsError`, the brands (including compile-time
disjointness vs pg-meta brands), and safe composition.

Verification: `pnpm test:studio` (new suites, 26 passed), `pnpm
typecheck`, `lint:ratchet` (no new warnings), and Prettier all pass.

## Additional context

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added the ability to run user-authored logs SQL with resolved
start/end timestamps.
* Normalized query error handling so failures surface a clear message
(including sensible fallbacks) and integrates with mutation error flows
(with a default error toast when not customized).
* Introduced safety branding for logs SQL fragments, including promotion
to runnable safe SQL.
* **Tests**
* Added tests covering error normalization across multiple
malformed/empty error shapes.
* Added tests ensuring logs SQL branding preserves/accepts only the
intended types and rejects unsafe inputs.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-28 10:26:30 -04:00

173 lines
6.2 KiB
TypeScript

import {
acceptUntrustedSql as pgAcceptUntrustedSql,
safeSql as pgSafeSql,
untrustedSql as pgUntrustedSql,
} from '@supabase/pg-meta'
import { describe, expect, it } from 'vitest'
import { executeAnalyticsSql } from './execute-analytics-sql'
import {
acceptUntrustedLogsSql,
analyticsLiteral,
keyword,
quotedIdent,
safeSql,
untrustedLogSql,
} from './safe-analytics-sql'
describe('keyword', () => {
it('returns the matching SafeLogSqlFragment from the allow-list', () => {
expect(keyword('AND', [safeSql`AND`, safeSql`OR`])).toBe('AND')
expect(keyword('=', [safeSql`=`, safeSql`!=`, safeSql`LIKE`])).toBe('=')
})
it('throws when value is not in the allow-list', () => {
expect(() => keyword('DROP', [safeSql`AND`, safeSql`OR`])).toThrow(
'"DROP" is not in the allowed list'
)
})
it('is case-insensitive and returns the allow-listed fragment', () => {
// 'and' (lowercase) matches 'AND' in the allow-list; the returned value
// is the allow-listed fragment 'AND', not the raw input 'and'.
expect(keyword('and', [safeSql`AND`, safeSql`OR`])).toBe('AND')
expect(keyword('OR', [safeSql`and`, safeSql`or`])).toBe('or')
})
it('throws for an empty allow-list', () => {
expect(() => keyword('AND', [])).toThrow()
})
})
describe('quotedIdent', () => {
it('backtick-quotes a single-segment identifier', () => {
expect(quotedIdent('status')).toBe('`status`')
})
it('backtick-quotes each segment of a two-part dotted path individually', () => {
expect(quotedIdent('request.method')).toBe('`request`.`method`')
})
it('backtick-quotes each segment of a three-part dotted path individually', () => {
expect(quotedIdent('a.b.c')).toBe('`a`.`b`.`c`')
})
it('throws for an empty string', () => {
expect(() => quotedIdent('')).toThrow('invalid identifier')
})
it('throws when a segment contains disallowed characters', () => {
expect(() => quotedIdent('request.method; DROP TABLE')).toThrow('invalid identifier')
})
it('throws for an empty segment (double dot)', () => {
expect(() => quotedIdent('a..b')).toThrow('invalid identifier')
})
})
describe('executeAnalyticsSql — compile-time boundary', () => {
it('accepts a SafeLogSqlFragment', () => {
const sql = safeSql`SELECT 1`
// SafeLogSqlFragment is accepted — no type error expected here.
const fn = () =>
executeAnalyticsSql({
projectRef: 'test-ref',
endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all',
sql,
iso_timestamp_start: '2024-01-01T00:00:00Z',
iso_timestamp_end: '2024-01-02T00:00:00Z',
})
expect(fn).toBeDefined()
})
it('rejects a plain string at the type level', () => {
const plainSql: string = 'SELECT 1'
// Never invoked — compile-time check only. The function is defined so
// TypeScript type-checks the body, but no network request is made.
const _check = () =>
executeAnalyticsSql({
projectRef: 'test-ref',
endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all',
// @ts-expect-error — plain string must not be assignable to SafeLogSqlFragment
sql: plainSql,
iso_timestamp_start: '2024-01-01T00:00:00Z',
iso_timestamp_end: '2024-01-02T00:00:00Z',
})
expect(_check).toBeDefined()
})
it('rejects rawSql output cast back to string at the type level', () => {
const fragment = safeSql`SELECT 1`
const asString: string = fragment as string
// Never invoked — compile-time check only.
const _check = () =>
executeAnalyticsSql({
projectRef: 'test-ref',
endpoint: '/platform/projects/{ref}/analytics/endpoints/logs.all',
// @ts-expect-error — widened-to-string value must not be assignable to SafeLogSqlFragment
sql: asString,
iso_timestamp_start: '2024-01-01T00:00:00Z',
iso_timestamp_end: '2024-01-02T00:00:00Z',
})
expect(_check).toBeDefined()
})
})
describe('untrusted logs SQL brands', () => {
it('untrustedLogSql preserves the raw text', () => {
expect(untrustedLogSql('SELECT 1 FROM logs')).toBe('SELECT 1 FROM logs')
})
it('acceptUntrustedLogsSql promotes untrusted text to a runnable fragment unchanged', () => {
const untrusted = untrustedLogSql('SELECT 1 FROM logs')
const safe = acceptUntrustedLogsSql(untrusted)
expect(safe).toBe('SELECT 1 FROM logs')
})
it('a promoted logs fragment composes via safeSql', () => {
const safe = acceptUntrustedLogsSql(untrustedLogSql('SELECT 1'))
expect(safeSql`${safe}`).toBe('SELECT 1')
})
it('rejects an unbranded string at the acceptUntrustedLogsSql boundary', () => {
const plain: string = 'SELECT 1'
// @ts-expect-error — only UntrustedLogSqlFragment may be promoted
const _check = () => acceptUntrustedLogsSql(plain)
expect(_check).toBeDefined()
})
it('rejects Postgres-branded fragments at the logs boundary (brands are disjoint)', () => {
// The logs boundary must not accept Postgres SQL, whether it is still untrusted or
// already promoted.
const pgUntrusted = pgUntrustedSql('SELECT 1')
const pgSafe = pgAcceptUntrustedSql(pgSafeSql`SELECT 1`)
const _check = () => [
// @ts-expect-error — pg untrusted brand is not assignable to UntrustedLogSqlFragment
acceptUntrustedLogsSql(pgUntrusted),
// @ts-expect-error — pg safe brand is not assignable to UntrustedLogSqlFragment
acceptUntrustedLogsSql(pgSafe),
]
expect(_check).toBeDefined()
})
})
describe('safeSql template tag — existing helpers still compose', () => {
it('analyticsLiteral output is composable via safeSql', () => {
const val = analyticsLiteral('hello')
const query = safeSql`SELECT ${val}`
expect(query).toBe("SELECT 'hello'")
})
it('quotedIdent output is composable via safeSql', () => {
const col = quotedIdent('request.method')
const query = safeSql`SELECT ${col} FROM logs`
expect(query).toBe('SELECT `request`.`method` FROM logs')
})
it('keyword output is composable via safeSql', () => {
const op = keyword('AND', [safeSql`AND`, safeSql`OR`])
const query = safeSql`WHERE a = 1 ${op} b = 2`
expect(query).toBe('WHERE a = 1 AND b = 2')
})
})