jsm-utilities
Version:
A utilities library.
792 lines (791 loc) • 29.6 kB
JavaScript
"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;