import { InvalidCriteriaError } from "./exceptions.js"; import { CriteriaAdapter, CriteriaOptions, FieldPath, Filter, FilterOperator, FilterValueFor, OperatorsForType, Order, OrderDirection, Pagination, PathValue, QueryParamsObject, Search, TypedFilter, TypedOrder, } from "./types/index.js"; import { isValidOperatorForType, getValidOperatorsForType, sanitizeFieldValue, isOperator, } from "./utils/criteria-operator-validation.js"; import { parseQueryValue } from "./utils/helpers.js"; export class Criteria { private _filters: Filter, any>[] = []; private _orders: Order[] = []; private _pagination: Pagination = { page: 1, limit: 20, offset: 0 }; private _search?: Search; private _adapter?: CriteriaAdapter; private constructor() {} static create(): Criteria { return new Criteria(); } useAdapter>(map: A): this { this._adapter = map; return this; } getAdapter(): CriteriaAdapter | undefined { return this._adapter; } where>( field: K, operator: OperatorsForType>>, value?: FilterValueFor>, options?: CriteriaOptions ): this; where>( field: K, operator: FilterOperator, value?: FilterValueFor>, options?: CriteriaOptions ): this { this.validateOperator(operator, value); this._filters.push({ field: this.resolveFieldPath(field), operator, value, options, }); return this; } whereEquals>(field: K, value: PathValue): this { return this.where( field, "equals" as OperatorsForType>, value ); } whereContains>( field: K, value: PathValue ): this { return this.where( field, "contains" as OperatorsForType>, value ); } whereIn>(field: K, values: PathValue[]): this { return this.where(field, "in" as OperatorsForType>, values); } whereBetween>( field: K, min: PathValue, max: PathValue ): this { return this.where( field, "between" as OperatorsForType>, [min, max] as [PathValue, PathValue] ); } whereNull>(field: K): this { return this.where(field, "isNull" as OperatorsForType>); } whereNotNull>(field: K): this { return this.where(field, "isNotNull" as OperatorsForType>); } whereSome>( field: K, operator: OperatorsForType>>, value?: FilterValueFor> ): this { return this.where(field, operator, value, { quantifier: "some" }); } whereEvery>( field: K, operator: OperatorsForType>>, value?: FilterValueFor> ): this { return this.where(field, operator, value, { quantifier: "every" }); } whereNone>( field: K, operator: OperatorsForType>>, value?: FilterValueFor> ): this { return this.where(field, operator, value, { quantifier: "none" }); } orderBy>( field: K, direction: OrderDirection = "asc" ): this { this._orders.push({ field: this.resolveFieldPath(field), direction, }); return this; } orderByAsc>(field: K): this { return this.orderBy(field, "asc"); } orderByDesc>(field: K): this { return this.orderBy(field, "desc"); } search(value: string): this { this._search = value; return this; } hasSearch(): boolean { return !!this._search; } getSearch(): Search | undefined { return this._search; } paginate(page: number, limit: number): this { if (page < 1) page = 1; if (limit < 1) limit = 10; this._pagination = { page, limit, offset: (page - 1) * limit, }; return this; } limit(limit: number): this { return this.paginate(1, limit); } getFilters(): Filter[] { return this._filters.map((filter) => ({ field: this.resolveFieldPath(filter.field), operator: filter.operator, value: filter.value, options: filter.options, })); } getOrders(): Order[] { return this._orders.map((order) => ({ field: this.resolveFieldPath(order.field as FieldPath), direction: order.direction, })); } getPagination(): Pagination { return this._pagination; } hasFilters(): boolean { return this._filters.length > 0; } hasOrders(): boolean { return this._orders.length > 0; } hasPagination(): boolean { return this._pagination !== undefined; } clone(): Criteria { const cloned = Criteria.create(); cloned._filters = [ ...this._filters.map((filter) => ({ field: this.resolveFieldPath(filter.field), operator: filter.operator, value: filter.value, options: filter.options, })), ]; cloned._orders = [ ...this._orders.map((order) => ({ field: this.resolveFieldPath(order.field as FieldPath), direction: order.direction, })), ]; cloned._pagination = { ...this._pagination }; cloned._search = this._search; if (this._adapter) { cloned.useAdapter(this._adapter); } return cloned; } toJSON() { return { filters: this._filters.map((filter) => ({ field: this.resolveFieldPath(filter.field), operator: filter.operator, value: filter.value, options: filter.options, })), orders: this._orders.map((order) => ({ field: this.resolveFieldPath(order.field as FieldPath), direction: order.direction, })), pagination: this._pagination, search: this._search, }; } static fromObject( obj: { filters?: TypedFilter[]; orders?: TypedOrder[]; pagination?: Pagination; search?: Search; }, adapter?: CriteriaAdapter ): Criteria { const criteria = Criteria.create(); if (adapter) { criteria.useAdapter(adapter); } if (obj.filters) { for (const filter of obj.filters) { filter.field = criteria.resolveFieldPath(filter.field); criteria.validateOperator(filter.operator, filter.value); } criteria._filters = [...obj.filters]; } if (obj.orders) criteria._orders = [ ...obj.orders.map((order) => ({ field: criteria.resolveFieldPath(order.field as FieldPath), direction: order.direction, })), ]; if (obj.pagination) criteria._pagination = { ...obj.pagination }; if (obj.search) criteria._search = obj.search; return criteria; } protected resolveFieldPath(field: FieldPath): FieldPath { if (!this?._adapter) return field; if (this._adapter[field]) { return this._adapter[field] as FieldPath; } const parts = field.split("."); for (let i = parts.length; i > 0; i--) { const prefix = parts.slice(0, i).join("."); if (this._adapter[prefix]) { const rest = parts.slice(i).join("."); return rest ? (`${this._adapter[prefix]}.${rest}` as FieldPath) : (this._adapter[prefix] as FieldPath); } } return field; } static fromQueryParams( query: QueryParamsObject | undefined, adapter?: CriteriaAdapter ): Criteria { if (!query) return Criteria.create(); const criteria = Criteria.create(); if (adapter) { criteria.useAdapter(adapter); } for (const [key, value] of Object.entries(query)) { if (key === "pagination") { continue; } if (key === "filters") { const filters: Record = criteria.parseFilterValue(value); for (let [filterKey, filterValue] of Object.entries(filters)) { const [field, operatorWithQuantifier] = filterKey.split(":"); if (!operatorWithQuantifier || !field) continue; const [operatorRaw, quantifierRaw] = operatorWithQuantifier.split("@"); const operator = isOperator(operatorRaw) ? operatorRaw : null; if (!operator) { throw new InvalidCriteriaError( `Invalid filter operator`, operatorRaw ); } const validQuantifiers = ["some", "every", "none"]; const quantifier = quantifierRaw && validQuantifiers.includes(quantifierRaw) ? (quantifierRaw as CriteriaOptions["quantifier"]) : undefined; if (quantifierRaw && !quantifier) { throw new InvalidCriteriaError( `Invalid quantifier. Valid values: ${validQuantifiers.join( ", " )}`, quantifierRaw ); } const options: CriteriaOptions | undefined = quantifier ? { quantifier } : undefined; let parsedValue: any = filterValue; const resolvedField = criteria.resolveFieldPath( field as FieldPath ); if (operator === "between") { parsedValue = criteria .parseFilterValue(filterValue) .map((v: any) => { if (typeof v === "string") { return parseQueryValue(v.trim()); } return parseQueryValue(v); }); if (parsedValue.length === 2) { criteria.where( resolvedField, "between" as OperatorsForType>>, [parsedValue[0], parsedValue[1]] as [ PathValue>, PathValue>, ], options ); } continue; } if (operator === "in" || operator === "notIn") { parsedValue = criteria .parseFilterValue(filterValue) .map(parseQueryValue); criteria.where( field as any, operator as OperatorsForType>>, parsedValue, options ); continue; } const parsedFinalValue = parseQueryValue(filterValue); criteria.validateOperator(operator, parsedFinalValue); criteria.where( field as FieldPath, operator as OperatorsForType>>, parsedFinalValue, options ); } } } function parsePagination(pagination: T | string) { if (typeof pagination === "string") { try { return JSON.parse(pagination) as T; } catch { return undefined; } } return pagination; } const pagination = parsePagination(query.pagination); const page = pagination?.page; const limit = pagination?.limit; if (page && limit) { criteria.paginate(Number(page), Number(limit)); } else if (limit) { criteria.paginate(1, Number(limit)); } // 1. orderBy=["field:asc","field2:desc"] if (query.orderBy) { const orderByValue = query.orderBy; if (Array.isArray(orderByValue)) { orderByValue.forEach((item: string) => { const [field, direction] = item.split(":"); criteria.orderBy( field as FieldPath, (direction as OrderDirection) || "asc" ); }); } } if (query.search && typeof query.search === "string") { criteria.search(query.search); } return criteria; } toQueryObject(): QueryParamsObject { const obj: QueryParamsObject = {}; const json = this.toJSON(); if (json.filters && json.filters.length > 0) { const filtersObj: Record = {}; for (const filter of json.filters) { let filterKey = `${filter.field}:${filter.operator}`; if (filter.options && filter.options.quantifier) { filterKey += `@${filter.options.quantifier}`; } let value: string | undefined; if (filter.value !== undefined) { if (Array.isArray(filter.value)) { value = JSON.stringify(filter.value); } else { if (filter.value instanceof Date) { value = filter.value.toISOString(); } else { value = String(filter.value); } } } else { value = ""; } filtersObj[filterKey] = value; } obj.filters = filtersObj; } if (json.pagination) { obj.pagination = json.pagination; } if (json.orders && json.orders.length > 0) { const sortValue = json.orders.map( (order) => `${order.field}:${order.direction}` ); obj.orderBy = sortValue; } if (json.search) { obj.search = json.search; } return obj; } toQueryParams() { const params = new URLSearchParams(); const object = this.toQueryObject(); if (object?.filters) { params.set("filters", JSON.stringify(object.filters)); } if (object?.pagination) { params.set("page", String(object.pagination.page)); params.set("limit", String(object.pagination.limit)); } if (object?.orderBy) { params.set("orderBy", JSON.stringify(object.orderBy)); } if (object?.search) { params.set("search", object.search); } return params; } private validateOperator(operator: FilterOperator, value: any): void { const sanitizedValue = sanitizeFieldValue(value, operator); if ( sanitizedValue !== undefined && !isValidOperatorForType(sanitizedValue, operator) ) { const validOps = getValidOperatorsForType(sanitizedValue); throw new InvalidCriteriaError( `Operator "${operator}" is not valid for type "${typeof sanitizedValue}". Valid operators: ${validOps.join( ", " )}`, operator ); } } private parseFilterValue(value: any) { if (typeof value === "string") { try { return JSON.parse(value); } catch { throw new InvalidCriteriaError(`Invalid filter value`, value); } } return parseQueryValue(value); } }