import pgPromise from 'pg-promise' import {QueryError} from './errors' import {nameQuery} from './naming' import {StandardSchemaV1} from './standard-schema/contract' import {looksLikeStandardSchema, looksLikeStandardSchemaFailure} from './standard-schema/utils' import {pgkitStorage} from './storage' import { SQLTagFunction, SQLMethodHelpers, SQLQuery, SQLTagHelpers, SQLParameter, AwaitSqlResultArray, Queryable, AwaitableSQLQuery, SQLTag, } from './types' /** a monadish `.map` which allows for promises or regular values and becomes a promise-or-regular-value with the return type of the mapper */ const maybeAsyncMap = (thing: T | Promise, map: (value: T) => U): U | Promise => { if (typeof (thing as {then?: unknown})?.then === 'function') { return (thing as {then: (mapper: typeof map) => Promise}).then(map) } return map(thing as T) } /** * Template tag function. Walks through each string segment and parameter, and concatenates them into a valid SQL query. */ const sqlFn: SQLTagFunction = (strings, ...templateParameters) => { return sqlFnInner({}, strings, ...templateParameters) } const sqlMethodHelpers: SQLMethodHelpers = { raw: (query: string, values: unknown[] = []): SQLQuery => ({ sql: query, parse: input => input as T, name: nameQuery([query]), token: 'sql', values, segments: () => [query], templateArgs: () => [[query]], }), type: typer(undefined, sqlFn), } function typer(client: Queryable | undefined, sqlFnImpl: typeof sqlFn): SQLMethodHelpers['type'] { return (type: StandardSchemaV1) => { if (!looksLikeStandardSchema(type)) { const typeName = (type as {})?.constructor?.name const hint = typeName?.includes('Zod') ? ` Try upgrading zod to v3.24.0 or greater` : '' throw new Error(`Invalid type parser. Must be a Standard Schema. Got ${typeName}.${hint}`, { cause: type, }) } const {validate} = type['~standard'] const parseFn = (input: unknown) => { return maybeAsyncMap(validate(input), result => { if (looksLikeStandardSchemaFailure(result)) throw result as {} as Error return result.value }) } return (strings, ...parameters) => { const {then, parse, ...rest} = sqlFnImpl(strings, ...parameters) const baseQuery = {...rest, parse: parseFn} // eslint-disable-next-line unicorn/no-thenable return {...baseQuery, then: getThenFn({client, sqlQuery: baseQuery})} } } } const sqlFnInner = ( {priorValues = 0, client = undefined as Queryable | undefined, parse = (x => x) as SQLQuery['parse']}, strings: TemplateStringsArray, ...templateParameters: SQLParameter[] ): SQLQuery & {then: (callback: (rows: Promise>) => U) => Promise} => { const segments: string[] = [] const values: unknown[] = [] const getValuePlaceholder = (inc = 0) => '$' + (values.length + priorValues + inc) // eslint-disable-next-line complexity strings.forEach((string, i) => { segments.push(string) if (!(i in templateParameters)) { return } const param = templateParameters[i] if (!param || typeof param !== 'object') { values.push(param ?? null) segments.push(getValuePlaceholder()) return } switch (param.token) { case 'array': { const [arrayValues, memberType] = param.args values.push(arrayValues) segments.push(getValuePlaceholder(), `::${memberType}[]`) break } case 'binary': case 'date': case 'json': case 'jsonb': case 'timestamp': { const [value] = param.args values.push(value) segments.push(getValuePlaceholder()) break } case 'literalValue': { const [value] = param.args segments.push(pgPromise.as.value(value)) break } case 'interval': { const [interval] = param.args segments.push('make_interval(') Object.entries(interval).forEach(([unit, value], j, {length}) => { values.push(unit) segments.push(getValuePlaceholder() + ':name') values.push(value) segments.push(` => ${getValuePlaceholder()}`) if (j < length - 1) segments.push(', ') }) segments.push(')') break } case 'join': { const [members, glue] = param.args members.forEach((value, j, {length}) => { if (value && typeof value === 'object' && value?.token === 'sql') { const innerArgs = value.templateArgs() as Parameters const innerResult = sqlFnInner({priorValues: values.length + priorValues}, ...innerArgs) segments.push(...innerResult.segments()) values.push(...innerResult.values) } else { values.push(value) segments.push(getValuePlaceholder()) } if (j < length - 1) segments.push(glue.sql) }) break } case 'identifier': { const [names] = param.args names.forEach((name, j, {length}) => { values.push(name) segments.push(getValuePlaceholder() + ':name') if (j < length - 1) segments.push('.') }) break } case 'unnest': { const [tuples, columnTypes] = param.args segments.push('unnest(') columnTypes.forEach((typename, j, {length}) => { const valueArray = tuples.map(tuple => tuple[j]) values.push(valueArray) segments.push(getValuePlaceholder() + '::' + typename + '[]') if (j < length - 1) segments.push(', ') }) segments.push(')') break } case 'fragment': { const innerArgs = param.args as Parameters const innerResult = sqlFnInner({priorValues: values.length}, ...innerArgs) segments.push(...innerResult.segments()) values.push(...innerResult.values) break } case 'sql': { const innerArgs = param.templateArgs() as Parameters const innerResult = sqlFnInner({priorValues: values.length}, ...innerArgs) segments.push(...innerResult.segments()) values.push(...innerResult.values) break } default: { // satisfies never ensures exhaustive const unexpected = param satisfies never as (typeof templateParameters)[number] throw new QueryError( `Unknown type ${unexpected && typeof unexpected === 'object' ? unexpected.token : typeof unexpected}`, {query: {name: nameQuery(strings), sql: segments.join(''), values: templateParameters}}, ) } } }) const sqlQuery: SQLQuery = { parse, name: nameQuery(strings), sql: segments.join(''), token: 'sql', values, segments: () => segments, templateArgs: () => [strings, ...templateParameters], } return { ...sqlQuery, // eslint-disable-next-line unicorn/no-thenable then: getThenFn({client, sqlQuery}), } } function getThenFn({client, sqlQuery}: {client: Queryable | undefined; sqlQuery: SQLQuery}) { return (onSuccess: (rows: Promise>) => U) => { const getAwaitSqlResultArrayAsync = async (): Promise> => { const queryable = client || pgkitStorage.getStore()?.client if (!queryable) { const msg = 'No client provided to sql tag - either use `createSqlTag({client})` or provide it with `pgkitStorage.run({client}, () => ...)`' throw new Error(msg) } return queryable.any(sqlQuery).then(rows => { const errorParams: QueryError.Params = {query: sqlQuery, result: {rows}} const getOne = () => { if (rows.length !== 1) throw new QueryError(`Expected 1 row, got ${rows.length}`, errorParams) return rows[0] } const getMany = () => { if (rows.length === 0) throw new QueryError('Expected at least 1 row, got 0', errorParams) return rows } Object.defineProperties(rows, { one: { get: getOne, }, maybeOne: { get() { if (rows.length > 1) throw new QueryError(`Expected either 0 or 1 row, got ${rows.length}`, errorParams) return rows.length === 1 ? rows[0] : null }, }, oneFirst: { get() { return Object.values(getOne())[0] }, }, maybeOneFirst: { get() { return rows.length === 1 ? Object.values(rows[0])[0] : null }, }, many: {get: getMany}, manyFirst: { get() { return getMany().map(row => Object.values(row)[0]) }, }, noNulls: { get() { for (const [i, row] of rows.entries()) { for (const [key, value] of Object.entries(row)) { if (value === null) throw new QueryError(`Null value at row ${i} column ${key}`, errorParams) } } return rows }, }, }) // eslint-disable-next-line promise/no-callback-in-promise return rows as AwaitSqlResultArray }) } return getAwaitSqlResultArrayAsync().then( value => onSuccess(Promise.resolve(value)), error => onSuccess(Promise.reject(error as Error)), ) } } export const sqlTagHelpers: SQLTagHelpers = { array: (...args) => ({token: 'array', args}), binary: (...args) => ({token: 'binary', args}), date: (...args) => ({token: 'date', args}), fragment: (...args) => ({token: 'fragment', args}), identifier: (...args) => ({token: 'identifier', args}), interval: (...args) => ({token: 'interval', args}), join: (...args) => ({token: 'join', args}), json: (...args) => ({token: 'json', args}), jsonb: (...args) => ({token: 'jsonb', args}), literalValue: (...args) => ({token: 'literalValue', args}), timestamp: (...args) => ({token: 'timestamp', args}), unnest: (...args) => ({token: 'unnest', args}), } export const allSqlHelpers = {...sqlMethodHelpers, ...sqlTagHelpers} export const sql: SQLTag = Object.assign(sqlFn, allSqlHelpers) // export function createSqlTag>(params: {typeAliases: TypeAliases}) export const createSqlTag = >(params: { typeAliases?: TypeAliases client?: Queryable }) => { // eslint-disable-next-line func-name-matching, func-names, @typescript-eslint/no-shadow const fn = function sql(...args: Parameters) { return sqlFnInner(params, ...args) } as >( strings: TemplateStringsArray, ...parameters: Row extends {'~parameters': SQLParameter[]} ? Row['~parameters'] : SQLParameter[] ) => AwaitableSQLQuery : Row> const {type, ...rest} = allSqlHelpers return Object.assign( fn, rest, {type: typer(params.client, (...args) => sqlFnInner(params, ...args))}, { typeAlias(name: K) { const schema = params.typeAliases?.[name] if (!schema) throw new Error(`Type alias ${name as string} not found`) // eslint-disable-next-line @typescript-eslint/no-explicit-any type Result = typeof schema extends StandardSchemaV1 ? R : never return sql.type(schema) as ( strings: TemplateStringsArray, ...parameters: Parameters ) => SQLQuery }, }, ) }