From 06f020dca1ee27ceae2f66955b6be34bc449df0b Mon Sep 17 00:00:00 2001 From: Andrey Avtomonov <7889985+andreybavt@users.noreply.github.com> Date: Thu, 14 May 2026 17:55:23 +0200 Subject: [PATCH] feat(context): expose read-only SQL validation port --- .../http-sql-analysis-port.test.ts | 38 +++++++++++++++++++ .../sql-analysis/http-sql-analysis-port.ts | 24 ++++++++++++ packages/context/src/sql-analysis/index.ts | 1 + packages/context/src/sql-analysis/ports.ts | 6 +++ 4 files changed, 69 insertions(+) diff --git a/packages/context/src/sql-analysis/http-sql-analysis-port.test.ts b/packages/context/src/sql-analysis/http-sql-analysis-port.test.ts index 6e22fd47..2d759369 100644 --- a/packages/context/src/sql-analysis/http-sql-analysis-port.test.ts +++ b/packages/context/src/sql-analysis/http-sql-analysis-port.test.ts @@ -108,6 +108,44 @@ describe('createHttpSqlAnalysisPort', () => { }); }); + it('maps read-only SQL validation responses', async () => { + const requests: Array<{ path: string; payload: Record }> = []; + const port = createHttpSqlAnalysisPort({ + baseUrl: 'http://127.0.0.1:8765', + requestJson: async (path, payload) => { + requests.push({ path, payload }); + return { ok: false, error: 'SQL contains read/write operation: Insert' }; + }, + }); + + await expect( + port.validateReadOnly('with x as (insert into t values (1)) select * from x', 'postgres'), + ).resolves.toEqual({ + ok: false, + error: 'SQL contains read/write operation: Insert', + }); + expect(requests).toEqual([ + { + path: '/sql/validate-read-only', + payload: { + dialect: 'postgres', + sql: 'with x as (insert into t values (1)) select * from x', + }, + }, + ]); + }); + + it('rejects malformed read-only validation responses', async () => { + const port = createHttpSqlAnalysisPort({ + baseUrl: 'http://127.0.0.1:8765', + requestJson: async () => ({ ok: 'yes' }), + }); + + await expect(port.validateReadOnly('select 1', 'postgres')).rejects.toThrow( + 'sql analysis response is missing boolean field ok', + ); + }); + it('rejects malformed SQL batch responses instead of inventing defaults', async () => { const requestJson = vi.fn(async () => ({ results: { diff --git a/packages/context/src/sql-analysis/http-sql-analysis-port.ts b/packages/context/src/sql-analysis/http-sql-analysis-port.ts index 9da37556..238b8863 100644 --- a/packages/context/src/sql-analysis/http-sql-analysis-port.ts +++ b/packages/context/src/sql-analysis/http-sql-analysis-port.ts @@ -9,6 +9,7 @@ import type { SqlAnalysisLiteralSlot, SqlAnalysisLiteralSlotType, SqlAnalysisPort, + SqlReadOnlyValidationResult, } from './ports.js'; export type KtxSqlAnalysisHttpJsonRunner = ( @@ -96,6 +97,14 @@ function requiredStringArray(raw: Record, field: string): strin return value; } +function requiredBoolean(raw: Record, field: string): boolean { + const value = raw[field]; + if (typeof value !== 'boolean') { + throw new Error(`sql analysis response is missing boolean field ${field}`); + } + return value; +} + function requiredObject(raw: Record, field: string): Record { const value = raw[field]; if (!value || typeof value !== 'object' || Array.isArray(value)) { @@ -187,6 +196,14 @@ function mapBatchResponse(raw: Record): Map): SqlReadOnlyValidationResult { + const error = optionalString(raw, 'error'); + return { + ok: requiredBoolean(raw, 'ok'), + ...(error !== undefined ? { error } : {}), + }; +} + export function createHttpSqlAnalysisPort(options: HttpSqlAnalysisPortOptions): SqlAnalysisPort { const requestJson = options.requestJson ?? postJson(options.baseUrl); @@ -205,5 +222,12 @@ export function createHttpSqlAnalysisPort(options: HttpSqlAnalysisPortOptions): }); return mapBatchResponse(raw); }, + async validateReadOnly(sql: string, dialect: SqlAnalysisDialect) { + const raw = await requestJson('/sql/validate-read-only', { + dialect, + sql, + }); + return mapReadOnlyValidation(raw); + }, }; } diff --git a/packages/context/src/sql-analysis/index.ts b/packages/context/src/sql-analysis/index.ts index 8338b822..c01a8aaa 100644 --- a/packages/context/src/sql-analysis/index.ts +++ b/packages/context/src/sql-analysis/index.ts @@ -9,4 +9,5 @@ export type { SqlAnalysisLiteralSlot, SqlAnalysisLiteralSlotType, SqlAnalysisPort, + SqlReadOnlyValidationResult, } from './ports.js'; diff --git a/packages/context/src/sql-analysis/ports.ts b/packages/context/src/sql-analysis/ports.ts index 3361a7c4..891515b7 100644 --- a/packages/context/src/sql-analysis/ports.ts +++ b/packages/context/src/sql-analysis/ports.ts @@ -38,10 +38,16 @@ export interface SqlAnalysisBatchResult { error?: string | null; } +export interface SqlReadOnlyValidationResult { + ok: boolean; + error?: string | null; +} + export interface SqlAnalysisPort { analyzeForFingerprint(sql: string, dialect: SqlAnalysisDialect): Promise; analyzeBatch( items: SqlAnalysisBatchItem[], dialect: SqlAnalysisDialect, ): Promise>; + validateReadOnly(sql: string, dialect: SqlAnalysisDialect): Promise; }