UNPKG

@overture-stack/lyric

Version:
142 lines (141 loc) 5.03 kB
import { and, not, or, sql } from 'drizzle-orm'; import * as _ from 'lodash-es'; import { ZodError } from 'zod'; import SQONBuilder, { CombinationKeys, GreaterThanFilter, isArrayFilter, isCombination, isFilter, LesserThanFilter, } from '@overture-stack/sqon-builder'; import { BadRequest } from './errors.js'; // Column name on the database used to build JSONB query const jsonbColumnName = 'data'; const isGreaterThanFilter = (operator) => GreaterThanFilter.safeParse(operator).success; const isLesserThanFilter = (operator) => LesserThanFilter.safeParse(operator).success; // Map the array and format each element based on its type const formatForSQL = (value) => { if (Array.isArray(value)) { // Handle array of strings or numbers return value .map((element) => { if (typeof element === 'string') { return `'${element}'`; // Surround strings with single quotes } else if (typeof element === 'number') { return element.toString(); // Numbers don't need quotes } else { throw new BadRequest(`Invalid SQON format. Unsupported data type: ${typeof element}`); } }) .join(', '); } else if (typeof value === 'string') { // Handle single string return value; } else if (typeof value === 'number') { // Handle single number return value; } throw new BadRequest(`Invalid SQON. Unsupported data type: ${typeof value}`); }; const processFilterOperator = (operator) => { const { fieldName, value } = operator.content; if (isArrayFilter(operator)) { // op is in return sql.raw(`${jsonbColumnName} ->> '${formatForSQL(fieldName)}' IN (${formatForSQL(value)})`); } else if (isGreaterThanFilter(operator)) { // is an scalar filter op is gt return sql.raw(`${jsonbColumnName} ->> '${formatForSQL(fieldName)}' > '${formatForSQL(value)}'`); } else if (isLesserThanFilter(operator)) { // is an scalar filter op is lt return sql.raw(`${jsonbColumnName} ->> '${formatForSQL(fieldName)}' < '${formatForSQL(value)}'`); } throw new BadRequest(`Invalid SQON format. Unsupported SQON filter operator`); }; const iterateOperator = (operator) => { if (isFilter(operator)) { // op in [in, lt, gt] return processFilterOperator(operator); } else if (isCombination(operator)) { // op in [and, or, not] return processCombinationOperator(operator); } throw new BadRequest(`Invalid SQON format. Unsupported SQON Operator`); }; const iterateOperators = (operators) => { return operators.map((operator) => iterateOperator(operator)); }; const processCombinationOperator = (sqon) => { switch (sqon.op) { case CombinationKeys.And: return andOperator(sqon.content); case CombinationKeys.Or: return orOperator(sqon.content); case CombinationKeys.Not: return notOperator(sqon.content); } }; const andOperator = (operations) => { const andSql = and(...iterateOperators(operations)); if (!andSql) { throw new BadRequest(`Invalid SQON format. Invalid 'and' operator`); } return andSql; }; const orOperator = (operations) => { const orSql = or(...iterateOperators(operations)); if (!orSql) { throw new BadRequest(`Invalid SQON format. Invalid 'or' operator`); } return orSql; }; const notOperator = (operations) => { return not(iterateOperator(operations[0])); }; /** * Main function to converts any SQON object to a partial SQL to query a JSONB column * The result query uses the operator ->> to get a JSON object field as text * * @example * Input: * { "op": "in", "content": { "fieldName": "country", "value": [ "Canada" ] } } * Output: * metadata ->> 'country' IN ('Canada') * * @param {Operator | undefined} sqon SQON input * @returns {SQL<unknown>} */ export const convertSqonToQuery = (sqon) => { if (!sqon || _.isEmpty(sqon)) { return undefined; } return iterateOperator(sqon); }; /** * Given any input, attempt to parse it as a SQON. * An error will be thrown if the provided input is invalid. * @param {unknown} input * @returns SQONBuilder */ export const parseSQON = (input) => { try { // Given any input, attempt to parse it as a SQON. // An error will be thrown if the provided input is invalid. return SQONBuilder.default.from(input); // TODO: SQL sanitization (https://github.com/overture-stack/lyric/issues/43) } catch (error) { if (isZodError(error)) { throw new BadRequest('Invalid SQON format', error.issues); } } }; const isZodError = (error) => { if (error instanceof ZodError) { return true; } if (error && typeof error === 'object' && 'name' in error && error.name === 'ZodError') { return true; } return false; };