mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
Initial open-source release
This commit is contained in:
commit
1a42152e6f
1199 changed files with 257054 additions and 0 deletions
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createHttpSqlAnalysisPort } from './http-sql-analysis-port.js';
|
||||
|
||||
describe('createHttpSqlAnalysisPort', () => {
|
||||
it('calls the python-service fingerprint endpoint and maps snake_case response fields', async () => {
|
||||
const requestJson = vi.fn(async () => ({
|
||||
fingerprint: 'fingerprint-template',
|
||||
normalized_sql: 'SELECT * FROM analytics.orders WHERE status = ?',
|
||||
tables_touched: ['analytics.orders'],
|
||||
literal_slots: [{ position: 1, type: 'string', example_value: 'paid' }],
|
||||
}));
|
||||
const port = createHttpSqlAnalysisPort({ baseUrl: 'http://python.test', requestJson });
|
||||
|
||||
await expect(
|
||||
port.analyzeForFingerprint("SELECT * FROM analytics.orders WHERE status = 'paid'", 'postgres'),
|
||||
).resolves.toEqual({
|
||||
fingerprint: 'fingerprint-template',
|
||||
normalizedSql: 'SELECT * FROM analytics.orders WHERE status = ?',
|
||||
tablesTouched: ['analytics.orders'],
|
||||
literalSlots: [{ position: 1, type: 'string', exampleValue: 'paid' }],
|
||||
});
|
||||
|
||||
expect(requestJson).toHaveBeenCalledWith('/api/sql/analyze-for-fingerprint', {
|
||||
sql: "SELECT * FROM analytics.orders WHERE status = 'paid'",
|
||||
dialect: 'postgres',
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves python-service parse errors in the mapped result', async () => {
|
||||
const requestJson = vi.fn(async () => ({
|
||||
fingerprint: '',
|
||||
normalized_sql: '',
|
||||
tables_touched: [],
|
||||
literal_slots: [],
|
||||
error: 'Invalid expression / Unexpected token',
|
||||
}));
|
||||
const port = createHttpSqlAnalysisPort({ baseUrl: 'http://python.test', requestJson });
|
||||
|
||||
await expect(port.analyzeForFingerprint('SELECT * FROM WHERE', 'postgres')).resolves.toEqual({
|
||||
fingerprint: '',
|
||||
normalizedSql: '',
|
||||
tablesTouched: [],
|
||||
literalSlots: [],
|
||||
error: 'Invalid expression / Unexpected token',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects malformed daemon responses instead of inventing defaults', async () => {
|
||||
const requestJson = vi.fn(async () => ({
|
||||
fingerprint: 'abc',
|
||||
normalized_sql: 'SELECT ?',
|
||||
tables_touched: 'orders',
|
||||
literal_slots: [],
|
||||
}));
|
||||
const port = createHttpSqlAnalysisPort({ baseUrl: 'http://python.test', requestJson });
|
||||
|
||||
await expect(port.analyzeForFingerprint('SELECT 1', 'postgres')).rejects.toThrow(
|
||||
'sql analysis response is missing string[] field tables_touched',
|
||||
);
|
||||
});
|
||||
});
|
||||
159
packages/context/src/sql-analysis/http-sql-analysis-port.ts
Normal file
159
packages/context/src/sql-analysis/http-sql-analysis-port.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { request as httpRequest } from 'node:http';
|
||||
import { request as httpsRequest } from 'node:https';
|
||||
import { URL } from 'node:url';
|
||||
import type {
|
||||
SqlAnalysisDialect,
|
||||
SqlAnalysisFingerprintResult,
|
||||
SqlAnalysisLiteralSlot,
|
||||
SqlAnalysisLiteralSlotType,
|
||||
SqlAnalysisPort,
|
||||
} from './ports.js';
|
||||
|
||||
export type KloSqlAnalysisHttpJsonRunner = (
|
||||
path: string,
|
||||
payload: Record<string, unknown>,
|
||||
) => Promise<Record<string, unknown>>;
|
||||
|
||||
export interface HttpSqlAnalysisPortOptions {
|
||||
baseUrl: string;
|
||||
requestJson?: KloSqlAnalysisHttpJsonRunner;
|
||||
}
|
||||
|
||||
function normalizedBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
|
||||
}
|
||||
|
||||
function parseJsonObject(raw: string, path: string): Record<string, unknown> {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`sql analysis HTTP ${path} returned non-object JSON`);
|
||||
}
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function postJson(baseUrl: string): KloSqlAnalysisHttpJsonRunner {
|
||||
return async (path, payload) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const target = new URL(path.replace(/^\//, ''), normalizedBaseUrl(baseUrl));
|
||||
const body = JSON.stringify(payload);
|
||||
const client = target.protocol === 'https:' ? httpsRequest : httpRequest;
|
||||
const request = client(
|
||||
target,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
'content-length': Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
response.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
const statusCode = response.statusCode ?? 0;
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
reject(new Error(`sql analysis HTTP ${path} failed with ${statusCode}: ${text}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(parseJsonObject(text, path));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
request.on('error', reject);
|
||||
request.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
function requiredString(raw: Record<string, unknown>, field: string): string {
|
||||
const value = raw[field];
|
||||
if (typeof value !== 'string') {
|
||||
throw new Error(`sql analysis response is missing string field ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(raw: Record<string, unknown>, field: string): string | null | undefined {
|
||||
const value = raw[field];
|
||||
if (value === null || value === undefined || typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`sql analysis response has invalid optional string field ${field}`);
|
||||
}
|
||||
|
||||
function requiredStringArray(raw: Record<string, unknown>, field: string): string[] {
|
||||
const value = raw[field];
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
||||
throw new Error(`sql analysis response is missing string[] field ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isLiteralSlotType(value: unknown): value is SqlAnalysisLiteralSlotType {
|
||||
return (
|
||||
value === 'string' ||
|
||||
value === 'number' ||
|
||||
value === 'timestamp' ||
|
||||
value === 'date' ||
|
||||
value === 'boolean' ||
|
||||
value === 'null' ||
|
||||
value === 'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
function literalSlots(raw: Record<string, unknown>): SqlAnalysisLiteralSlot[] {
|
||||
const value = raw.literal_slots;
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error('sql analysis response is missing literal_slots array');
|
||||
}
|
||||
return value.map((item) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
throw new Error('sql analysis response contains invalid literal slot');
|
||||
}
|
||||
const slot = item as Record<string, unknown>;
|
||||
if (typeof slot.position !== 'number') {
|
||||
throw new Error('sql analysis response literal slot is missing numeric position');
|
||||
}
|
||||
if (!isLiteralSlotType(slot.type)) {
|
||||
throw new Error('sql analysis response literal slot is missing valid type');
|
||||
}
|
||||
if (typeof slot.example_value !== 'string') {
|
||||
throw new Error('sql analysis response literal slot is missing example_value');
|
||||
}
|
||||
return {
|
||||
position: slot.position,
|
||||
type: slot.type,
|
||||
exampleValue: slot.example_value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mapResult(raw: Record<string, unknown>): SqlAnalysisFingerprintResult {
|
||||
const error = optionalString(raw, 'error');
|
||||
return {
|
||||
fingerprint: requiredString(raw, 'fingerprint'),
|
||||
normalizedSql: requiredString(raw, 'normalized_sql'),
|
||||
tablesTouched: requiredStringArray(raw, 'tables_touched'),
|
||||
literalSlots: literalSlots(raw),
|
||||
...(error !== undefined ? { error } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createHttpSqlAnalysisPort(options: HttpSqlAnalysisPortOptions): SqlAnalysisPort {
|
||||
const requestJson = options.requestJson ?? postJson(options.baseUrl);
|
||||
|
||||
return {
|
||||
async analyzeForFingerprint(sql: string, dialect: SqlAnalysisDialect) {
|
||||
const raw = await requestJson('/api/sql/analyze-for-fingerprint', {
|
||||
sql,
|
||||
dialect,
|
||||
});
|
||||
return mapResult(raw);
|
||||
},
|
||||
};
|
||||
}
|
||||
9
packages/context/src/sql-analysis/index.ts
Normal file
9
packages/context/src/sql-analysis/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { createHttpSqlAnalysisPort } from './http-sql-analysis-port.js';
|
||||
export type { HttpSqlAnalysisPortOptions, KloSqlAnalysisHttpJsonRunner } from './http-sql-analysis-port.js';
|
||||
export type {
|
||||
SqlAnalysisDialect,
|
||||
SqlAnalysisFingerprintResult,
|
||||
SqlAnalysisLiteralSlot,
|
||||
SqlAnalysisLiteralSlotType,
|
||||
SqlAnalysisPort,
|
||||
} from './ports.js';
|
||||
30
packages/context/src/sql-analysis/ports.ts
Normal file
30
packages/context/src/sql-analysis/ports.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export type SqlAnalysisDialect =
|
||||
| 'bigquery'
|
||||
| 'snowflake'
|
||||
| 'postgres'
|
||||
| 'redshift'
|
||||
| 'mysql'
|
||||
| 'sqlite'
|
||||
| 'tsql'
|
||||
| 'clickhouse'
|
||||
| (string & {});
|
||||
|
||||
export type SqlAnalysisLiteralSlotType = 'string' | 'number' | 'timestamp' | 'date' | 'boolean' | 'null' | 'unknown';
|
||||
|
||||
export interface SqlAnalysisLiteralSlot {
|
||||
position: number;
|
||||
type: SqlAnalysisLiteralSlotType;
|
||||
exampleValue: string;
|
||||
}
|
||||
|
||||
export interface SqlAnalysisFingerprintResult {
|
||||
fingerprint: string;
|
||||
normalizedSql: string;
|
||||
tablesTouched: string[];
|
||||
literalSlots: SqlAnalysisLiteralSlot[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface SqlAnalysisPort {
|
||||
analyzeForFingerprint(sql: string, dialect: SqlAnalysisDialect): Promise<SqlAnalysisFingerprintResult>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue