mirror of
https://github.com/freedbygrace/ActiveDirectoryManager.git
synced 2026-07-26 11:59:14 +00:00
d77ce2adbe
Replit-Commit-Author: Agent Replit-Commit-Session-Id: 705f2157-ef97-4fbd-89e4-8c7f2ecaea90 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7ed01c5f-a82d-405a-b728-b2e3d127c60c/30fe5a86-4e58-47d8-aa32-104aaf3a85ce.jpg
352 lines
9.9 KiB
TypeScript
352 lines
9.9 KiB
TypeScript
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<T extends PgTableWithColumns<any>>(
|
|
table: T,
|
|
conditions: FilterCondition[]
|
|
): SQL<unknown> | 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<unknown>[]);
|
|
}
|
|
|
|
/**
|
|
* 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<T extends PgTableWithColumns<any>>(
|
|
table: T,
|
|
orderByStr?: string
|
|
): SQL<unknown>[] {
|
|
if (!orderByStr) return [];
|
|
|
|
const parts = orderByStr.split(',').map(p => p.trim()).filter(Boolean);
|
|
const orders: SQL<unknown>[] = [];
|
|
|
|
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<T extends PgTableWithColumns<any>>(
|
|
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)
|
|
`;
|
|
} |