mirror of
https://github.com/Kaelio/ktx.git
synced 2026-06-10 08:05:14 +02:00
233 lines
7.7 KiB
TypeScript
233 lines
7.7 KiB
TypeScript
import { request as httpRequest } from 'node:http';
|
|
import { request as httpsRequest } from 'node:https';
|
|
import { URL } from 'node:url';
|
|
import type {
|
|
SqlAnalysisBatchItem,
|
|
SqlAnalysisBatchResult,
|
|
SqlAnalysisDialect,
|
|
SqlAnalysisFingerprintResult,
|
|
SqlAnalysisLiteralSlot,
|
|
SqlAnalysisLiteralSlotType,
|
|
SqlAnalysisPort,
|
|
SqlReadOnlyValidationResult,
|
|
} from './ports.js';
|
|
|
|
export type KtxSqlAnalysisHttpJsonRunner = (
|
|
path: string,
|
|
payload: Record<string, unknown>,
|
|
) => Promise<Record<string, unknown>>;
|
|
|
|
export interface HttpSqlAnalysisPortOptions {
|
|
baseUrl: string;
|
|
requestJson?: KtxSqlAnalysisHttpJsonRunner;
|
|
}
|
|
|
|
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): KtxSqlAnalysisHttpJsonRunner {
|
|
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 requiredBoolean(raw: Record<string, unknown>, 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<string, unknown>, field: string): Record<string, unknown> {
|
|
const value = raw[field];
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error(`sql analysis response is missing object field ${field}`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
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 } : {}),
|
|
};
|
|
}
|
|
|
|
function mapColumnsByClause(raw: Record<string, unknown>): SqlAnalysisBatchResult['columnsByClause'] {
|
|
const value = requiredObject(raw, 'columns_by_clause');
|
|
const result: SqlAnalysisBatchResult['columnsByClause'] = {};
|
|
for (const [clause, columns] of Object.entries(value)) {
|
|
if (!Array.isArray(columns) || columns.some((item) => typeof item !== 'string')) {
|
|
throw new Error(`sql analysis response is missing string[] field columns_by_clause.${clause}`);
|
|
}
|
|
result[clause] = columns;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function mapBatchResult(raw: Record<string, unknown>): SqlAnalysisBatchResult {
|
|
const error = optionalString(raw, 'error');
|
|
return {
|
|
tablesTouched: requiredStringArray(raw, 'tables_touched'),
|
|
columnsByClause: mapColumnsByClause(raw),
|
|
...(error !== undefined ? { error } : {}),
|
|
};
|
|
}
|
|
|
|
function mapBatchResponse(raw: Record<string, unknown>): Map<string, SqlAnalysisBatchResult> {
|
|
const results = requiredObject(raw, 'results');
|
|
return new Map(
|
|
Object.entries(results).map(([id, value]) => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
throw new Error(`sql analysis response contains invalid batch result ${id}`);
|
|
}
|
|
return [id, mapBatchResult(value as Record<string, unknown>)];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function mapReadOnlyValidation(raw: Record<string, unknown>): 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);
|
|
|
|
return {
|
|
async analyzeForFingerprint(sql: string, dialect: SqlAnalysisDialect) {
|
|
const raw = await requestJson('/api/sql/analyze-for-fingerprint', {
|
|
sql,
|
|
dialect,
|
|
});
|
|
return mapResult(raw);
|
|
},
|
|
async analyzeBatch(items: SqlAnalysisBatchItem[], dialect: SqlAnalysisDialect) {
|
|
const raw = await requestJson('/sql/analyze-batch', {
|
|
dialect,
|
|
items,
|
|
});
|
|
return mapBatchResponse(raw);
|
|
},
|
|
async validateReadOnly(sql: string, dialect: SqlAnalysisDialect) {
|
|
const raw = await requestJson('/sql/validate-read-only', {
|
|
dialect,
|
|
sql,
|
|
});
|
|
return mapReadOnlyValidation(raw);
|
|
},
|
|
};
|
|
}
|