import { encodeArgument, type EncodedArg } from "./encode.ts";import { type Column, decode } from "./decode.ts";import { type Notice } from "../connection/message.ts";import { type ClientControls } from "../connection/connection_params.ts";
export type QueryArguments = unknown[] | Record<string, unknown>;
const commandTagRegexp = /^([A-Za-z]+)(?: (\d+))?(?: (\d+))?/;
export type CommandType = | "INSERT" | "DELETE" | "UPDATE" | "SELECT" | "MOVE" | "FETCH" | "COPY";
export enum ResultType { ARRAY, OBJECT,}
export class RowDescription { constructor(public columnCount: number, public columns: Column[]) {}}
export function templateStringToQuery<T extends ResultType>( template: TemplateStringsArray, args: unknown[], result_type: T,): Query<T> { const text = template.reduce((curr, next, index) => { return `${curr}$${index}${next}`; });
return new Query(text, result_type, args);}
function objectQueryToQueryArgs( query: string, args: Record<string, unknown>,): [string, unknown[]] { args = normalizeObjectQueryArgs(args);
let counter = 0; const clean_args: unknown[] = []; const clean_query = query.replaceAll(/(?<=\$)\w+/g, (match) => { match = match.toLowerCase(); if (match in args) { clean_args.push(args[match]); } else { throw new Error( `No value was provided for the query argument "${match}"`, ); }
return String(++counter); });
return [clean_query, clean_args];}
function normalizeObjectQueryArgs( args: Record<string, unknown>,): Record<string, unknown> { const normalized_args = Object.fromEntries( Object.entries(args).map(([key, value]) => [key.toLowerCase(), value]), );
if (Object.keys(normalized_args).length !== Object.keys(args).length) { throw new Error( "The arguments provided for the query must be unique (insensitive)", ); }
return normalized_args;}
export interface QueryOptions { args?: QueryArguments; encoder?: (arg: unknown) => EncodedArg; name?: string; text: string;}
export interface QueryObjectOptions extends QueryOptions { camelCase?: boolean; fields?: string[];}
export class QueryResult { public command!: CommandType; public rowCount?: number; #row_description?: RowDescription; public warnings: Notice[] = [];
get rowDescription(): RowDescription | undefined { return this.#row_description; }
set rowDescription(row_description: RowDescription | undefined) { if (row_description && !this.#row_description) { this.#row_description = row_description; } }
constructor(public query: Query<ResultType>) {}
loadColumnDescriptions(description: RowDescription) { this.rowDescription = description; }
handleCommandComplete(commandTag: string): void { const match = commandTagRegexp.exec(commandTag); if (match) { this.command = match[1] as CommandType; if (match[3]) { this.rowCount = parseInt(match[3], 10); } else { this.rowCount = parseInt(match[2], 10); } } }
insertRow(_row: Uint8Array[]): void { throw new Error("No implementation for insertRow is defined"); }}
export class QueryArrayResult< T extends Array<unknown> = Array<unknown>,> extends QueryResult { public rows: T[] = [];
insertRow(row_data: Uint8Array[], controls?: ClientControls) { if (!this.rowDescription) { throw new Error( "The row descriptions required to parse the result data weren't initialized", ); }
const row = row_data.map((raw_value, index) => { const column = this.rowDescription!.columns[index];
if (raw_value === null) { return null; } return decode(raw_value, column, controls); }) as T;
this.rows.push(row); }}
function findDuplicatesInArray(array: string[]): string[] { return array.reduce((duplicates, item, index) => { const is_duplicate = array.indexOf(item) !== index; if (is_duplicate && !duplicates.includes(item)) { duplicates.push(item); }
return duplicates; }, [] as string[]);}
function snakecaseToCamelcase(input: string) { return input.split("_").reduce((res, word, i) => { if (i !== 0) { word = word[0].toUpperCase() + word.slice(1); }
res += word; return res; }, "");}
export class QueryObjectResult< T = Record<string, unknown>,> extends QueryResult { public columns?: string[]; public rows: T[] = [];
insertRow(row_data: Uint8Array[], controls?: ClientControls) { if (!this.rowDescription) { throw new Error( "The row description required to parse the result data wasn't initialized", ); }
if (!this.columns) { if (this.query.fields) { if (this.rowDescription.columns.length !== this.query.fields.length) { throw new RangeError( "The fields provided for the query don't match the ones returned as a result " + `(${this.rowDescription.columns.length} expected, ${this.query.fields.length} received)`, ); }
this.columns = this.query.fields; } else { let column_names: string[]; if (this.query.camelCase) { column_names = this.rowDescription.columns.map((column) => snakecaseToCamelcase(column.name) ); } else { column_names = this.rowDescription.columns.map( (column) => column.name, ); }
const duplicates = findDuplicatesInArray(column_names); if (duplicates.length) { throw new Error( `Field names ${ duplicates .map((str) => `"${str}"`) .join(", ") } are duplicated in the result of the query`, ); }
this.columns = column_names; } }
const columns = this.columns!;
if (columns.length !== row_data.length) { throw new RangeError( "The result fields returned by the database don't match the defined structure of the result", ); }
const row = row_data.reduce((row, raw_value, index) => { const current_column = this.rowDescription!.columns[index];
if (raw_value === null) { row[columns[index]] = null; } else { row[columns[index]] = decode(raw_value, current_column, controls); }
return row; }, {} as Record<string, unknown>);
this.rows.push(row as T); }}
export class Query<T extends ResultType> { public args: EncodedArg[]; public camelCase?: boolean; public fields?: string[]; public result_type: ResultType; public text: string; constructor(config: QueryObjectOptions, result_type: T); constructor(text: string, result_type: T, args?: QueryArguments); constructor( config_or_text: string | QueryObjectOptions, result_type: T, args: QueryArguments = [], ) { this.result_type = result_type; if (typeof config_or_text === "string") { if (!Array.isArray(args)) { [config_or_text, args] = objectQueryToQueryArgs(config_or_text, args); }
this.text = config_or_text; this.args = args.map(encodeArgument); } else { const { camelCase, encoder = encodeArgument, fields } = config_or_text; let { args = [], text } = config_or_text;
if (fields) { const fields_are_clean = fields.every((field) => /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(field) ); if (!fields_are_clean) { throw new TypeError( "The fields provided for the query must contain only letters and underscores", ); }
if (new Set(fields).size !== fields.length) { throw new TypeError( "The fields provided for the query must be unique", ); }
this.fields = fields; }
this.camelCase = camelCase;
if (!Array.isArray(args)) { [text, args] = objectQueryToQueryArgs(text, args); }
this.args = args.map(encoder); this.text = text; } }}