UNPKG

jsm-utilities

Version:
792 lines (791 loc) 29.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createQueryBuilder = exports.QueryFilterBuilder = exports.createNumberFilter_deprecated = exports.createDateRangeFilter_deprecated = exports.createBooleanFilter_deprecated = exports.createStringFilter_deprecated = exports.createConfigurableFilter = exports.createTextSearchFilter = exports.createGeoFilter = exports.createArrayFilter = exports.createNumberFilter = exports.createDateRangeFilter = exports.createBooleanFilter = exports.createStringFilter = void 0; const mongodb_1 = require("mongodb"); const numbers_1 = require("../../../common/numbers"); /** * Enhanced string filter with support for multiple fields and various matching modes */ const createStringFilter = (q, totalQ, value, fields, options = {}) => { const { omit = false, operator = "or", mode = "exact", caseSensitive = false, useRegex = false } = options; // Handle ObjectId conversion if (mongodb_1.ObjectId.isValid(value)) { value = value.toString(); } // Handle object with numeric keys if (value && typeof value === "object" && "0" in value && "1" in value) { value = Object.values(value); } // Skip empty values if (!value && value !== null && value !== 0 && value !== false) { return { q, totalQ }; } // Normalize fields to array const fieldArray = Array.isArray(fields) ? fields : [fields]; // Create filter value based on mode const createFilterValue = (val) => { if (val instanceof Array) { return { [omit ? "$nin" : "$in"]: val }; } if (val === null) { return omit ? { $ne: null } : null; } if (typeof val !== "string") { return omit ? { $ne: val } : val; } if (!useRegex) { // Direct equality comparison without regex return omit ? { $ne: val } : val; } switch (mode) { case "contains": return omit ? { $not: { $regex: val, $options: caseSensitive ? "" : "i" } } : { $regex: val, $options: caseSensitive ? "" : "i" }; case "regex": return omit ? { $not: { $regex: val, $options: caseSensitive ? "" : "i" } } : { $regex: val, $options: caseSensitive ? "" : "i" }; case "startsWith": return omit ? { $not: { $regex: `^${val}`, $options: caseSensitive ? "" : "i" } } : { $regex: `^${val}`, $options: caseSensitive ? "" : "i" }; case "endsWith": return omit ? { $not: { $regex: `${val}$`, $options: caseSensitive ? "" : "i" } } : { $regex: `${val}$`, $options: caseSensitive ? "" : "i" }; case "exact": default: if (!caseSensitive && typeof val === "string") { return omit ? { $not: { $regex: `^${val}$`, $options: "i" } } : { $regex: `^${val}$`, $options: "i" }; } return omit ? { $ne: val } : val; } }; const filterValue = createFilterValue(value); // Build query conditions if (fieldArray.length > 1) { const conditions = fieldArray.map(field => ({ [field]: filterValue })); const queryOperator = omit ? "$and" : (operator === "and" ? "$and" : "$or"); const totalQueryOperator = (omit || operator === "and") ? "$and" : "$or"; q = q === null || q === void 0 ? void 0 : q.where({ [queryOperator]: conditions }); totalQ = totalQ ? totalQ.where({ [totalQueryOperator]: conditions }) : totalQ; } else { const field = fieldArray[0]; const condition = { [field]: filterValue }; q = q === null || q === void 0 ? void 0 : q.where(condition); totalQ = totalQ ? totalQ.where(condition) : totalQ; } return { q, totalQ }; }; exports.createStringFilter = createStringFilter; /** * Enhanced boolean filter */ const createBooleanFilter = (q, totalQ, value, field, omit = false) => { if (value === null || value === undefined) { return { q, totalQ }; } const boolValue = Boolean(value); const condition = { [field]: omit ? { $ne: boolValue } : boolValue, }; q = q === null || q === void 0 ? void 0 : q.where(condition); totalQ = totalQ ? totalQ.where(condition) : totalQ; return { q, totalQ }; }; exports.createBooleanFilter = createBooleanFilter; /** * Enhanced date range filter */ const createDateRangeFilter = (q, totalQ, value, field, options = {}) => { const { omit = false, timezone, includeTime = true } = options; if (!value) { return { q, totalQ }; } let dateCondition = {}; if (value instanceof Array && value.length === 2) { const [start, end] = value; if (start) { dateCondition.$gte = includeTime ? new Date(start) : new Date(new Date(start).setHours(0, 0, 0, 0)); } if (end) { dateCondition.$lte = includeTime ? new Date(end) : new Date(new Date(end).setHours(23, 59, 59, 999)); } } else { // Single date const date = new Date(value); if (includeTime) { dateCondition = date; } else { dateCondition = { $gte: new Date(date.setHours(0, 0, 0, 0)), $lte: new Date(date.setHours(23, 59, 59, 999)) }; } } const condition = { [field]: omit ? { $not: dateCondition } : dateCondition, }; q = q === null || q === void 0 ? void 0 : q.where(condition); totalQ = totalQ ? totalQ.where(condition) : totalQ; return { q, totalQ }; }; exports.createDateRangeFilter = createDateRangeFilter; /** * Enhanced number filter with range support */ const createNumberFilter = (q, totalQ, value, field, options = {}) => { const { omit = false, allowZero = true, precision, operator = "or" } = options; if (!value && value !== 0) { return { q, totalQ }; } if (!allowZero && value === 0) { return { q, totalQ }; } // Handle precision rounding const processValue = (val) => { return precision !== undefined ? Number(val.toFixed(precision)) : val; }; // Handle range values if ((0, numbers_1.isNumberRange)(value)) { let rangeCondition = {}; if (value.exact !== undefined) { rangeCondition = processValue(value.exact); } else { if (value.min !== undefined) { rangeCondition.$gte = processValue(value.min); } if (value.max !== undefined) { rangeCondition.$lte = processValue(value.max); } } const fieldArray = Array.isArray(field) ? field : [field]; if (fieldArray.length > 1) { const conditions = fieldArray.map(f => ({ [f]: omit ? { $not: rangeCondition } : rangeCondition })); const queryOperator = omit ? "$and" : (operator === "and" ? "$and" : "$or"); q = q === null || q === void 0 ? void 0 : q.where({ [queryOperator]: conditions }); totalQ = totalQ ? totalQ.where({ [queryOperator]: conditions }) : totalQ; } else { const condition = { [fieldArray[0]]: omit ? { $not: rangeCondition } : rangeCondition, }; q = q === null || q === void 0 ? void 0 : q.where(condition); totalQ = totalQ ? totalQ.where(condition) : totalQ; } return { q, totalQ }; } // Handle array values if (value instanceof Array) { const processedValues = value.map(processValue); const condition = { [field]: { [omit ? "$nin" : "$in"]: processedValues, }, }; q = q === null || q === void 0 ? void 0 : q.where(condition); totalQ = totalQ ? totalQ.where(condition) : totalQ; return { q, totalQ }; } // Handle single values const processedValue = processValue(value); const condition = { [field]: omit ? { $ne: processedValue } : processedValue, }; q = q === null || q === void 0 ? void 0 : q.where(condition); totalQ = totalQ ? totalQ.where(condition) : totalQ; return { q, totalQ }; }; exports.createNumberFilter = createNumberFilter; /** * Array filter for matching array fields */ const createArrayFilter = (q, totalQ, value, field, options = {}) => { const { omit = false, matchAll = false, size } = options; if (!value) { return { q, totalQ }; } let condition = {}; if (size !== undefined) { condition[field] = { $size: size }; } else if (value instanceof Array) { const operator = matchAll ? "$all" : "$in"; condition[field] = omit ? { $not: { [operator]: value } } : { [operator]: value }; } else { condition[field] = omit ? { $ne: value } : value; } q = q === null || q === void 0 ? void 0 : q.where(condition); totalQ = totalQ ? totalQ.where(condition) : totalQ; return { q, totalQ }; }; exports.createArrayFilter = createArrayFilter; /** * Geospatial filter */ const createGeoFilter = (q, totalQ, geoFilter, field) => { if (!geoFilter) { return { q, totalQ }; } let condition = {}; switch (geoFilter.type) { case 'near': if (geoFilter.longitude !== undefined && geoFilter.latitude !== undefined) { condition[field] = { $near: Object.assign(Object.assign({ $geometry: { type: "Point", coordinates: [geoFilter.longitude, geoFilter.latitude] } }, (geoFilter.maxDistance && { $maxDistance: geoFilter.maxDistance })), (geoFilter.minDistance && { $minDistance: geoFilter.minDistance })) }; } break; case 'within': if (geoFilter.geometry) { condition[field] = { $geoWithin: { $geometry: geoFilter.geometry } }; } break; case 'intersects': if (geoFilter.geometry) { condition[field] = { $geoIntersects: { $geometry: geoFilter.geometry } }; } break; } if (Object.keys(condition).length > 0) { q = q === null || q === void 0 ? void 0 : q.where(condition); totalQ = totalQ ? totalQ.where(condition) : totalQ; } return { q, totalQ }; }; exports.createGeoFilter = createGeoFilter; /** * Text search filter */ const createTextSearchFilter = (q, totalQ, searchText, options = {}) => { if (!searchText) { return { q, totalQ }; } const condition = { $text: Object.assign(Object.assign(Object.assign({ $search: searchText }, (options.language && { $language: options.language })), (options.caseSensitive && { $caseSensitive: options.caseSensitive })), (options.diacriticSensitive && { $diacriticSensitive: options.diacriticSensitive })) }; q = q === null || q === void 0 ? void 0 : q.where(condition); totalQ = totalQ ? totalQ.where(condition) : totalQ; return { q, totalQ }; }; exports.createTextSearchFilter = createTextSearchFilter; /** * Configuration-driven filter function */ const createConfigurableFilter = (q, totalQ, filters, config) => { var _a; let result = { q, totalQ }; for (const [fieldName, filterValue] of Object.entries(filters)) { const filterConfig = config[fieldName]; if (!filterConfig || filterValue === undefined) { continue; } // Apply transform if provided const transformedValue = filterConfig.transform ? filterConfig.transform(filterValue) : filterValue; // Apply validator if provided if (filterConfig.validator && !filterConfig.validator(transformedValue)) { continue; } // Apply appropriate filter based on type switch (filterConfig.type) { case 'string': result = (0, exports.createStringFilter)(result.q, result.totalQ, transformedValue, fieldName, filterConfig.options); break; case 'number': result = (0, exports.createNumberFilter)(result.q, result.totalQ, transformedValue, fieldName, filterConfig.options); break; case 'date': result = (0, exports.createDateRangeFilter)(result.q, result.totalQ, transformedValue, fieldName, filterConfig.options); break; case 'boolean': result = (0, exports.createBooleanFilter)(result.q, result.totalQ, transformedValue, fieldName, (_a = filterConfig.options) === null || _a === void 0 ? void 0 : _a.omit); break; case 'array': result = (0, exports.createArrayFilter)(result.q, result.totalQ, transformedValue, fieldName, filterConfig.options); break; case 'geo': result = (0, exports.createGeoFilter)(result.q, result.totalQ, transformedValue, fieldName); break; } } return result; }; exports.createConfigurableFilter = createConfigurableFilter; // Legacy compatibility functions (deprecated) const createStringFilter_deprecated = (q, totalQ, value, fields, omit, operator) => { return (0, exports.createStringFilter)(q, totalQ, value, fields, { omit, operator }); }; exports.createStringFilter_deprecated = createStringFilter_deprecated; const createBooleanFilter_deprecated = (q, totalQ, value, field, omit) => { return (0, exports.createBooleanFilter)(q, totalQ, value, field, omit); }; exports.createBooleanFilter_deprecated = createBooleanFilter_deprecated; const createDateRangeFilter_deprecated = (q, totalQ, value, field, omit) => { return (0, exports.createDateRangeFilter)(q, totalQ, value, field, { omit }); }; exports.createDateRangeFilter_deprecated = createDateRangeFilter_deprecated; const createNumberFilter_deprecated = (q, totalQ, value, field, omit) => { return (0, exports.createNumberFilter)(q, totalQ, value, field, { omit }); }; exports.createNumberFilter_deprecated = createNumberFilter_deprecated; /** * Fluent filter builder for creating complex MongoDB queries with method chaining. * * This class provides a chainable interface for building complex database queries * by combining multiple filter types. It supports both main queries and total count queries * simultaneously, making it ideal for paginated results. * * @template F - Field names type constraint (extends string) * @template Q - Query type (typically a Mongoose Query object) * @template T - Total query type (typically a Mongoose Query object for counting) * * @example * ```typescript * const builder = createQueryBuilder(userQuery, totalQuery) * .string('name', 'john', { mode: 'contains', caseSensitive: false }) * .number('age', { min: 18, max: 65 }) * .boolean('isActive', true) * .dateRange('createdAt', [startDate, endDate]); * * const { q, totalQ } = builder.build(); * ``` * * @since 2.0.0 */ class QueryFilterBuilder { /** * Creates a new QueryFilterBuilder instance. * * @param q - The main query object to build filters on * @param totalQ - The total count query object (can be null) */ constructor(q, totalQ) { this.q = q; this.totalQ = totalQ; } /** * Adds a string filter to the query with flexible matching options. * * @param field - The field name(s) to filter on (single field or array of fields) * @param value - The string value to search for (can be any type, null, or undefined) * @param options - Configuration options for string matching * @param options.mode - Search mode: 'exact', 'contains', 'startsWith', 'endsWith' * @param options.caseSensitive - Whether to perform case-sensitive matching (default: false) * @param options.trim - Whether to trim whitespace from the value (default: true) * @param options.allowEmpty - Whether to include empty strings in results (default: false) * * @returns This QueryFilterBuilder instance for method chaining * * @example * ```typescript * builder * .string('name', 'john', { mode: 'contains', caseSensitive: false }) * .string(['firstName', 'lastName'], 'smith', { mode: 'contains' }); * ``` */ string(field, value, options) { const result = (0, exports.createStringFilter)(this.q, this.totalQ, value, field, options); this.q = result.q; this.totalQ = result.totalQ; return this; } /** * Adds a number filter to the query with range and equality options. * * @param field - The field name(s) to filter on (single field or array of fields) * @param value - The number value or range object to filter by * @param options - Configuration options for number filtering * @param options.allowDecimals - Whether to allow decimal values (default: true) * @param options.strictMode - Whether to enforce strict number validation (default: false) * * @returns This QueryFilterBuilder instance for method chaining * * @example * ```typescript * builder * .number('price', { min: 10, max: 100 }) * .number('age', 25) * .number(['width', 'height'], { min: 0 }, { allowDecimals: false }); * ``` */ number(field, value, options) { const result = (0, exports.createNumberFilter)(this.q, this.totalQ, value, field, options); this.q = result.q; this.totalQ = result.totalQ; return this; } /** * Adds a date range filter to the query for filtering by date ranges. * * @param field - The field name to filter on * @param value - The date range value (array of dates, single date, or date range object) * @param options - Configuration options for date range filtering * @param options.omit - Whether to omit null/undefined values (default: false) * @param options.timezone - Timezone to use for date calculations * @param options.includeTime - Whether to include time in date comparison (default: false) * @param options.format - Date format string for parsing * * @returns This QueryFilterBuilder instance for method chaining * * @example * ```typescript * const startDate = new Date('2023-01-01'); * const endDate = new Date('2023-12-31'); * * builder * .dateRange('createdAt', [startDate, endDate]) * .dateRange('updatedAt', { start: startDate, end: endDate }) * .dateRange('publishedAt', startDate); * ``` */ dateRange(field, value, options) { const result = (0, exports.createDateRangeFilter)(this.q, this.totalQ, value, field, options); this.q = result.q; this.totalQ = result.totalQ; return this; } /** * Add a boolean filter with chainable interface */ /** * Adds a boolean filter to the query for true/false value filtering. * * @param field - The field name to filter on * @param value - The boolean value to filter by * @param omit - Whether to omit null/undefined values (default: false) * * @returns This QueryFilterBuilder instance for method chaining * * @example * ```typescript * builder * .boolean('isActive', true) * .boolean('isPublished', false) * .boolean('isVerified', true, true); // omit null values * ``` */ boolean(field, value, omit) { const result = (0, exports.createBooleanFilter)(this.q, this.totalQ, value, field, omit); this.q = result.q; this.totalQ = result.totalQ; return this; } /** * Adds an array filter to the query for filtering by array values. * * @param field - The field name to filter on * @param value - The array value or element to filter by * @param options - Configuration options for array filtering * @param options.omit - Whether to omit null/undefined values (default: false) * @param options.matchAll - Whether to match all elements (AND) or any element (OR) * @param options.size - Filter arrays by their size/length * * @returns This QueryFilterBuilder instance for method chaining * * @example * ```typescript * builder * .array('tags', ['javascript', 'typescript']) * .array('categories', 'electronics', { matchAll: false }) * .array('permissions', [], { size: 0 }); // empty arrays * ``` */ array(field, value, options) { const result = (0, exports.createArrayFilter)(this.q, this.totalQ, value, field, options); this.q = result.q; this.totalQ = result.totalQ; return this; } /** * Adds a geospatial filter to the query for location-based filtering. * * @param field - The field name containing geospatial data * @param geoFilter - The geospatial filter configuration * * @returns This QueryFilterBuilder instance for method chaining * * @example * ```typescript * builder * .geo('location', { * near: { type: 'Point', coordinates: [-74.006, 40.7128] }, * maxDistance: 1000 // meters * }) * .geo('boundaries', { * within: { * type: 'Polygon', * coordinates: [[[...], [...], [...], [...]]] * } * }); * ``` */ geo(field, geoFilter) { const result = (0, exports.createGeoFilter)(this.q, this.totalQ, geoFilter, field); this.q = result.q; this.totalQ = result.totalQ; return this; } /** * Adds a text search filter to the query for full-text search capabilities. * * @param searchText - The text to search for * @param options - Configuration options for text search * @param options.language - Language for text search (default: 'english') * @param options.caseSensitive - Whether search is case sensitive (default: false) * @param options.diacriticSensitive - Whether search is diacritic sensitive (default: false) * * @returns This QueryFilterBuilder instance for method chaining * * @example * ```typescript * builder * .textSearch('javascript programming') * .textSearch('café', { language: 'french', diacriticSensitive: true }) * .textSearch('TYPESCRIPT', { caseSensitive: true }); * ``` */ textSearch(searchText, options) { const result = (0, exports.createTextSearchFilter)(this.q, this.totalQ, searchText, options); this.q = result.q; this.totalQ = result.totalQ; return this; } /** * Applies multiple filters using a configuration object. * * @param filters - Record of filter values keyed by field names * @param config - Configuration object defining how to apply filters * * @returns This QueryFilterBuilder instance for method chaining * * @example * ```typescript * const filters = { * name: 'john', * age: 25, * active: true * }; * const config = { * name: { type: 'string', match: 'contains' }, * age: { type: 'number', operator: 'gte' }, * active: { type: 'boolean' } * }; * * builder.configurable(filters, config); * ``` */ configurable(filters, config) { const result = (0, exports.createConfigurableFilter)(this.q, this.totalQ, filters, config); this.q = result.q; this.totalQ = result.totalQ; return this; } /** * Applies a custom filter function for advanced filtering scenarios. * * @param filterFn - Custom function that takes query objects and returns modified queries * * @returns This QueryFilterBuilder instance for method chaining * * @example * ```typescript * builder.custom((q, totalQ) => { * // Add complex MongoDB aggregation pipeline * q.push({ $lookup: { from: 'users', localField: 'userId', foreignField: '_id', as: 'user' } }); * return { q, totalQ }; * }); * * // Or for simple query modifications * builder.custom((q, totalQ) => { * q.sort = { createdAt: -1, priority: 1 }; * return { q, totalQ }; * }); * ``` */ custom(filterFn) { const result = filterFn(this.q, this.totalQ); this.q = result.q; this.totalQ = result.totalQ; return this; } /** * Builds and returns the final filter result containing both main and total queries. * * @returns FilterResult object containing the constructed query and totalQuery * * @example * ```typescript * const { q, totalQ } = builder * .string('name', 'john') * .number('age', 25, { operator: 'gte' }) * .build(); * * // Use with MongoDB/Mongoose * const results = await Model.find(q); * const total = await Model.countDocuments(totalQ || q); * ``` */ build() { return { q: this.q, totalQ: this.totalQ }; } /** * Gets the current main query object. * * @returns The current query object * * @example * ```typescript * const currentQuery = builder.string('name', 'john').getQuery(); * console.log(currentQuery); // { name: { $regex: 'john', $options: 'i' } } * ``` */ getQuery() { return this.q; } /** * Gets the current total query object used for counting. * * @returns The current total query object, or null if not set * * @example * ```typescript * const totalQuery = builder.string('name', 'john').getTotalQuery(); * const count = await Model.countDocuments(totalQuery || {}); * ``` */ getTotalQuery() { return this.totalQ; } /** * Resets the builder to a new initial state with fresh query objects. * * @param q - New initial query object * @param totalQ - New initial total query object (optional) * * @returns This QueryFilterBuilder instance for method chaining * * @example * ```typescript * // Reset with new base queries * builder.reset({}, {}) * .string('category', 'electronics') * .boolean('active', true); * * // Reset with pre-existing query * const baseQuery = { organizationId: '123' }; * builder.reset(baseQuery).string('name', 'product'); * ``` */ reset(q, totalQ = null) { this.q = q; this.totalQ = totalQ; return this; } /** * Creates a clone of the current builder with the same state. * * @returns A new QueryFilterBuilder instance with the same query and totalQuery state * * @example * ```typescript * const baseBuilder = createQueryBuilder({}, {}) * .string('organization', 'company1'); * * // Create variations from the base * const activeUsersQuery = baseBuilder.clone() * .boolean('active', true) * .build(); * * const inactiveUsersQuery = baseBuilder.clone() * .boolean('active', false) * .build(); * ``` */ clone() { return new QueryFilterBuilder(this.q, this.totalQ); } } exports.QueryFilterBuilder = QueryFilterBuilder; /** * Factory function to create a new QueryFilterBuilder instance with fluent interface. * * @template F - Union type of allowed field names for type safety * @template Q - Type of the main query object (e.g., MongoDB filter, Mongoose query) * @template T - Type of the total/count query object * * @param q - Initial query object to start building from * @param totalQ - Initial total query object for counting (optional) * * @returns A new QueryFilterBuilder instance ready for method chaining * * @example * ```typescript * // Basic usage with MongoDB-style queries * const builder = createQueryBuilder({}, {}) * .string('name', 'john') * .number('age', 25, { operator: 'gte' }) * .boolean('active', true); * * const { q, totalQ } = builder.build(); * * // With typed field names for better IntelliSense * type UserFields = 'name' | 'email' | 'age' | 'active'; * const typedBuilder = createQueryBuilder<UserFields>({}) * .string('name', 'john') // ✅ Type-safe * .number('invalidField', 123); // ❌ TypeScript error * * // With Mongoose Query and aggregation pipeline * interface MongoQuery { * [key: string]: any; * } * * const mongoBuilder = createQueryBuilder<string, MongoQuery>({}) * .string('title', 'typescript') * .dateRange('createdAt', [startDate, endDate]); * ``` */ const createQueryBuilder = (q, totalQ = null) => { return new QueryFilterBuilder(q, totalQ); }; exports.createQueryBuilder = createQueryBuilder;