import { SQL, and, asc, desc, eq, gt, gte, ilike, lt, lte, or, sql } from "drizzle-orm"; import { PgTableWithColumns } from "drizzle-orm/pg-core"; import debugLib from 'debug'; const debug = debugLib('api:query-parser'); export type FilterOperator = | 'eq' | 'ne' | 'gt' | 'ge' | 'lt' | 'le' | 'in' | 'nin' | 'contains' | 'containsi' | 'startswith' | 'endswith'; export interface FilterCondition { field: string; operator: FilterOperator; value: any; } export interface QueryOptions { filter?: string; select?: string; expand?: string; orderBy?: string; top?: string | number; skip?: string | number; } /** * Parse the filter query string into an array of filter conditions * Format examples: * - "name eq 'John'" * - "age gt 30 and (city eq 'New York' or city eq 'Boston')" * - "title contains 'Manager' and department eq 'IT' and createdAt gt '2023-01-01'" */ export function parseFilter(filterStr?: string): FilterCondition[] { if (!filterStr) return []; debug(`Parsing filter: ${filterStr}`); // Very basic parser for demonstration // Would need a proper parser for complex expressions with nested parentheses const conditions: FilterCondition[] = []; // Handle multiple conditions with 'and' const andParts = filterStr.split(' and '); andParts.forEach(part => { // Handle 'or' conditions (very simplistic, doesn't handle nested parentheses properly) if (part.includes(' or ')) { const orParts = part.replace(/[()]/g, '').split(' or '); orParts.forEach(orPart => { const condition = parseFilterExpression(orPart.trim()); if (condition) conditions.push(condition); }); } else { const condition = parseFilterExpression(part.trim()); if (condition) conditions.push(condition); } }); debug(`Parsed ${conditions.length} filter conditions`); return conditions; } /** * Parse a single filter expression like "name eq 'John'" or "age gt 30" */ function parseFilterExpression(expr: string): FilterCondition | null { // Match pattern: field operator value // Where value can be a quoted string, number, boolean, or null const regex = /^(\w+)\s+(eq|ne|gt|ge|lt|le|in|nin|contains|containsi|startswith|endswith)\s+(.+)$/; const match = expr.match(regex); if (!match) { debug(`Invalid filter expression: ${expr}`); return null; } const [, field, operator, rawValue] = match; // Parse the value based on type let value: any; if (rawValue.startsWith("'") && rawValue.endsWith("'")) { // String value value = rawValue.slice(1, -1); } else if (rawValue.toLowerCase() === 'true') { value = true; } else if (rawValue.toLowerCase() === 'false') { value = false; } else if (rawValue.toLowerCase() === 'null') { value = null; } else if (/^\d+$/.test(rawValue)) { // Integer value = parseInt(rawValue, 10); } else if (/^\d+\.\d+$/.test(rawValue)) { // Float value = parseFloat(rawValue); } else { // Fallback to string value = rawValue; } return { field, operator: operator as FilterOperator, value }; } /** * Convert filter conditions to SQL conditions */ export function applyFilterConditions>( table: T, conditions: FilterCondition[] ): SQL | undefined { if (conditions.length === 0) return undefined; const sqlConditions = conditions.map(condition => { const { field, operator, value } = condition; if (!table[field as keyof typeof table]) { debug(`Field not found in table: ${field}`); return undefined; } const column = table[field as keyof typeof table]; switch (operator) { case 'eq': return eq(column, value); case 'ne': return sql`${column} <> ${value}`; case 'gt': return gt(column, value); case 'ge': return gte(column, value); case 'lt': return lt(column, value); case 'le': return lte(column, value); case 'contains': return sql`${column} LIKE ${'%' + value + '%'}`; case 'containsi': return ilike(column, `%${value}%`); case 'startswith': return sql`${column} LIKE ${value + '%'}`; case 'endswith': return sql`${column} LIKE ${'%' + value}`; case 'in': // Expecting value to be comma-separated values const inValues = Array.isArray(value) ? value : String(value).split(',').map(v => v.trim()); return sql`${column} IN ${inValues}`; case 'nin': const ninValues = Array.isArray(value) ? value : String(value).split(',').map(v => v.trim()); return sql`${column} NOT IN ${ninValues}`; default: debug(`Unsupported operator: ${operator}`); return undefined; } }).filter(Boolean); if (sqlConditions.length === 0) return undefined; return and(...sqlConditions as SQL[]); } /** * Parse the select query parameter to get the fields to include */ export function parseSelect(selectStr?: string): string[] { if (!selectStr) return []; const fields = selectStr.split(',').map(f => f.trim()).filter(Boolean); debug(`Selected fields: ${fields.join(', ')}`); return fields; } /** * Parse the expand query parameter to get the relations to include */ export function parseExpand(expandStr?: string): string[] { if (!expandStr) return []; const relations = expandStr.split(',').map(r => r.trim()).filter(Boolean); debug(`Expanded relations: ${relations.join(', ')}`); return relations; } /** * Parse the orderBy query parameter * Format: "field asc" or "field desc" or just "field" (defaults to asc) */ export function parseOrderBy>( table: T, orderByStr?: string ): SQL[] { if (!orderByStr) return []; const parts = orderByStr.split(',').map(p => p.trim()).filter(Boolean); const orders: SQL[] = []; for (const part of parts) { const [field, direction = 'asc'] = part.split(' ').map(p => p.trim()); if (!table[field as keyof typeof table]) { debug(`Field not found for ordering: ${field}`); continue; } const column = table[field as keyof typeof table]; if (direction.toLowerCase() === 'desc') { orders.push(desc(column)); } else { orders.push(asc(column)); } } debug(`Order by: ${orderByStr} -> ${orders.length} clauses`); return orders; } /** * Parse pagination parameters */ export function parsePagination(top?: string | number, skip?: string | number): { limit?: number, offset?: number } { const limit = top !== undefined ? parseInt(String(top), 10) : undefined; const offset = skip !== undefined ? parseInt(String(skip), 10) : undefined; debug(`Pagination: limit=${limit}, offset=${offset}`); return { limit: !isNaN(Number(limit)) ? Number(limit) : undefined, offset: !isNaN(Number(offset)) ? Number(offset) : undefined }; } /** * Apply all query options to a database query */ export function applyQueryOptions>( table: T, options: QueryOptions ) { const conditions = parseFilter(options.filter); const whereClause = applyFilterConditions(table, conditions); const orderClauses = parseOrderBy(table, options.orderBy); const { limit, offset } = parsePagination(options.top, options.skip); return { whereClause, orderClauses, limit, offset, selectedFields: parseSelect(options.select), expandRelations: parseExpand(options.expand) }; } /** * Build a cache key based on query parameters */ export function buildCacheKey(baseKey: string, options: QueryOptions): string { const parts = [baseKey]; if (options.filter) parts.push(`filter=${options.filter}`); if (options.select) parts.push(`select=${options.select}`); if (options.expand) parts.push(`expand=${options.expand}`); if (options.orderBy) parts.push(`orderBy=${options.orderBy}`); if (options.top) parts.push(`top=${options.top}`); if (options.skip) parts.push(`skip=${options.skip}`); return parts.join(':'); } /** * Generate a documentation string for the query parameters */ export function getQueryParametersDocumentation(): string { return ` # Query Parameters Guide This API supports the following query parameters for filtering, selecting fields, and pagination: ## Filter Use the \`filter\` parameter to filter results based on field values. Syntax: \`field operator value\` Supported operators: - \`eq\`: Equals - \`ne\`: Not equals - \`gt\`: Greater than - \`ge\`: Greater than or equals - \`lt\`: Less than - \`le\`: Less than or equals - \`contains\`: Contains substring (case-sensitive) - \`containsi\`: Contains substring (case-insensitive) - \`startswith\`: Starts with - \`endswith\`: Ends with - \`in\`: In a list of values - \`nin\`: Not in a list of values Examples: - \`?filter=name eq 'John'\` - \`?filter=age gt 30\` - \`?filter=title contains 'Manager'\` - \`?filter=status in 'active,pending'\` - \`?filter=createdAt gt '2023-01-01'\` Multiple conditions can be combined with \`and\` and \`or\`: - \`?filter=name eq 'John' and age gt 30\` - \`?filter=(status eq 'active' or status eq 'pending') and createdAt gt '2023-01-01'\` ## Select Use the \`select\` parameter to specify which fields to include in the response. Example: \`?select=id,name,email\` If not specified, all fields will be returned. ## Expand Use the \`expand\` parameter to include related entities in the response. Example: \`?expand=department,role\` ## OrderBy Use the \`orderBy\` parameter to sort the results. Syntax: \`field direction\` Example: - \`?orderBy=name asc\` - \`?orderBy=createdAt desc\` - \`?orderBy=department asc,name desc\` (multiple fields) If direction is omitted, \`asc\` is used. ## Pagination Use the \`top\` and \`skip\` parameters for pagination. - \`top\`: Maximum number of records to return - \`skip\`: Number of records to skip Example: \`?top=10&skip=20\` (return records 21-30) `; }