UNPKG

mongoose-query-toolkit

Version:

A toolkit for handling Mongoose queries with support for search, filtering, pagination, sorting, field selection, and population

434 lines (432 loc) 15.8 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { QueryToolkit: () => QueryToolkit }); module.exports = __toCommonJS(index_exports); var OPERATOR_MAP = { eq: "$eq", ne: "$ne", gt: "$gt", gte: "$gte", lt: "$lt", lte: "$lte", in: "$in", nin: "$nin" }; var QueryToolkit = class { constructor(model, options = {}) { this.model = model; this.searchFields = options.searchFields || []; this.filterableFields = options.filterableFields || []; this.selectableFields = options.selectableFields || []; this.populatableFields = options.populatableFields || []; this.defaultLimit = options.defaultLimit ?? 10; this.maxLimit = options.maxLimit ?? 100; this.searchMode = options.searchMode ?? "regex"; this.leanByDefault = options.lean ?? false; this.splitCommaValues = options.splitCommaValues ?? false; } searchFields; filterableFields; selectableFields; populatableFields = []; defaultLimit; maxLimit; searchMode; leanByDefault; splitCommaValues; presets = /* @__PURE__ */ new Map(); /** * Escapes regex special characters to prevent regex injection and * catastrophic backtracking (ReDoS) from user-supplied search terms. */ escapeRegex(input) { return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } buildSearchQuery(q) { if (!q) return {}; if (this.searchMode === "text") { return { $text: { $search: q } }; } if (!this.searchFields.length) return {}; const safe = this.escapeRegex(q); return { $or: this.searchFields.map((field) => ({ [field]: { $regex: safe, $options: "i" } })) }; } buildFilterQuery(options) { const filterQuery = {}; for (const key of this.filterableFields) { const value = options[key]; if (value === void 0) continue; const built = this.buildFilterValue(value); if (built !== void 0) { filterQuery[key] = built; } } return filterQuery; } /** * Translates a single filter value into a safe Mongo query fragment: * - arrays become `$in` (multi-value filter); comma-separated strings also * become `$in` only when `splitCommaValues` is enabled (off by default, so * values that legitimately contain commas keep exact-match semantics) * - objects are treated as operator filters, keeping only whitelisted * operators (gte, lte, ne, in, ...) and dropping anything unrecognized * to block NoSQL operator injection ($where, $function, raw $-keys, ...) * - primitives become exact-match * Returns `undefined` when nothing safe could be derived. */ buildFilterValue(value) { if (value === null) return null; if (Array.isArray(value)) { const safe = value.filter((item) => this.isPrimitive(item)); return safe.length ? { $in: safe } : void 0; } if (this.isPrimitive(value)) { if (this.splitCommaValues && typeof value === "string" && value.includes(",")) { const parts = this.toMultiValue(value); return parts.length > 1 ? { $in: parts } : parts[0] ?? value; } return value; } if (typeof value === "object") { return this.buildOperatorFilter(value); } return void 0; } /** * Splits an array or comma-separated string into a deduped list of safe * primitive values (used by `$in`/`$nin` and comma-value filters). */ toMultiValue(raw) { const arr = Array.isArray(raw) ? raw : String(raw).split(",").map((part) => part.trim()).filter((part) => part.length > 0); return arr.filter((item) => this.isPrimitive(item)); } buildOperatorFilter(value) { const operators = {}; for (const op of Object.keys(value)) { if (!Object.prototype.hasOwnProperty.call(OPERATOR_MAP, op)) continue; const mapped = OPERATOR_MAP[op]; const raw = value[op]; if (op === "in" || op === "nin") { const safe = this.toMultiValue(raw); if (safe.length) operators[mapped] = safe; } else if (this.isPrimitive(raw)) { operators[mapped] = raw; } } return Object.keys(operators).length ? operators : void 0; } isPrimitive(value) { const type = typeof value; return type === "string" || type === "number" || type === "boolean"; } /** * Coerces and clamps pagination input. Query-string params arrive as * strings, so values are normalized to integers, page is forced to >= 1, * and limit is bounded to [1, maxLimit] to prevent unbounded scans. */ normalizePagination(page, limit) { const parsedPage = Math.floor(Number(page)); const safePage = Number.isFinite(parsedPage) && parsedPage >= 1 ? parsedPage : 1; return { page: safePage, limit: this.normalizeLimit(limit) }; } normalizeLimit(limit) { const parsed = Math.floor(Number(limit)); let safe = Number.isFinite(parsed) && parsed >= 1 ? parsed : this.defaultLimit; if (safe > this.maxLimit) safe = this.maxLimit; return safe; } parseSortString(sort) { const sortQuery = {}; if (!sort) return sortQuery; sort.split(",").forEach((field) => { const order = field.startsWith("-") ? -1 : 1; const fieldName = field.startsWith("-") ? field.substring(1) : field; sortQuery[fieldName] = order; }); return sortQuery; } buildSelectQuery(select) { if (!select) return null; let fields = select.split(",").map((field) => field.trim()).filter((field) => field.length > 0); if (this.selectableFields.length > 0) { fields = fields.filter((field) => { const fieldName = field.startsWith("-") ? field.substring(1) : field; return this.selectableFields.includes(fieldName); }); } if (fields.length === 0) return null; const hasInclusion = fields.some( (field) => !field.startsWith("-") ); const hasExclusion = fields.some((field) => field.startsWith("-")); if (hasInclusion && hasExclusion) { fields = fields.filter( (field) => !field.startsWith("-") || field === "-_id" ); } return fields.length > 0 ? fields.join(" ") : null; } /** * Parses the populate string into populate configs, optionally with * per-path field selection. * * Grammar (deterministic — `;` always separates paths, `,` separates the * selected fields of a single path): * - `profile,posts` → populate both paths (legacy, no `:`) * - `profile:name,avatar` → populate `profile` selecting name+avatar * - `profile:name;posts:title,body` → multiple paths, each with selection * - `profile;posts:title` → mix paths with and without selection */ buildPopulateFields(populate) { if (!populate) return []; const configs = []; for (const entry of populate.split(";")) { const trimmed = entry.trim(); if (!trimmed) continue; const colonIndex = trimmed.indexOf(":"); if (colonIndex === -1) { for (const rawPath of trimmed.split(",")) { this.addPopulateConfig(configs, rawPath.trim()); } continue; } const path = trimmed.slice(0, colonIndex).trim(); const select = trimmed.slice(colonIndex + 1).split(",").map((field) => field.trim()).filter((field) => field.length > 0).join(" "); this.addPopulateConfig(configs, path, select || void 0); } return configs; } addPopulateConfig(configs, path, select) { if (!path) return; if (this.populatableFields.length > 0 && !this.populatableFields.includes(path)) { return; } const config = { path }; if (select) config.select = select; configs.push(config); } /** * Assembles the base match query shared by every read method from the * search term and the whitelisted filter options. */ buildBaseQuery(q, filterOptions) { return { ...this.buildSearchQuery(q || ""), ...this.buildFilterQuery(filterOptions) }; } /** Returns the filter fields of an options object, excluding reserved keys. */ extractFilters(options) { const reserved = /* @__PURE__ */ new Set(["q", "page", "limit", "sort", "select", "populate", "lean"]); const filters = {}; for (const key of Object.keys(options)) { if (!reserved.has(key)) filters[key] = options[key]; } return filters; } /** Reads a possibly dotted path (e.g. `profile.score`) off a document. */ getValueByPath(doc, path) { if (doc == null) return void 0; if (path.indexOf(".") === -1) return doc[path]; return path.split(".").reduce((acc, key) => acc == null ? acc : acc[key], doc); } /** Encodes a cursor field value into a string that round-trips losslessly. */ encodeCursor(value) { if (value == null) return null; if (value instanceof Date) return value.toISOString(); return String(value); } applyCommonModifiers(findQuery, select, populate, lean) { const selectQuery = this.buildSelectQuery(select); if (selectQuery) { findQuery = findQuery.select(selectQuery); } const populateFields = this.buildPopulateFields(populate); populateFields.forEach((config) => { findQuery = findQuery.populate(config); }); if (lean ?? this.leanByDefault) { findQuery = findQuery.lean(); } return findQuery; } async findWithOptions(options = {}) { const { q, page: rawPage, limit: rawLimit, sort, select, populate, lean, ...filterOptions } = options; const { page, limit } = this.normalizePagination(rawPage, rawLimit); const skip = (page - 1) * limit; const query = this.buildBaseQuery(q, filterOptions); const sortQuery = this.parseSortString(sort); let findQuery = this.model.find(query); if (sortQuery && Object.keys(sortQuery).length > 0) { findQuery = findQuery.sort(sortQuery); } findQuery = this.applyCommonModifiers(findQuery, select, populate, lean); const [docs, totalDocs] = await Promise.all([ findQuery.skip(skip).limit(limit).exec(), this.model.countDocuments(query) ]); const totalPages = Math.ceil(totalDocs / limit); return { docs, totalDocs, limit, page, totalPages, hasNextPage: page < totalPages, hasPrevPage: page > 1 }; } /** * Cursor-based (keyset) pagination. Scales to large collections because it * avoids the growing `skip` cost of offset pagination. Pass the `nextCursor` * from the previous result back in as `cursor` to fetch the following page. */ async findWithCursor(options = {}) { const { q, limit: rawLimit, cursor, cursorField = "_id", direction = "asc", select, populate, lean, ...filterOptions } = options; const limit = this.normalizeLimit(rawLimit); const comparator = direction === "desc" ? "$lt" : "$gt"; const query = this.buildBaseQuery(q, filterOptions); if (cursor !== void 0 && cursor !== null && cursor !== "") { const cursorCondition = { [cursorField]: { [comparator]: cursor } }; if (query[cursorField] !== void 0) { const existing = { [cursorField]: query[cursorField] }; delete query[cursorField]; query.$and = [...query.$and || [], existing, cursorCondition]; } else { query[cursorField] = cursorCondition[cursorField]; } } let findQuery = this.model.find(query).sort({ [cursorField]: direction === "desc" ? -1 : 1 }); findQuery = this.applyCommonModifiers(findQuery, select, populate, lean); const docs = await findQuery.limit(limit + 1).exec(); const hasNextPage = docs.length > limit; const pageDocs = hasNextPage ? docs.slice(0, limit) : docs; let nextCursor = null; if (hasNextPage && pageDocs.length > 0) { const last = pageDocs[pageDocs.length - 1]; nextCursor = this.encodeCursor(this.getValueByPath(last, cursorField)); } return { docs: pageDocs, limit, nextCursor, hasNextPage }; } /** * Returns a single document matching the search/filter options, or null. * Supports select, populate and lean; pagination/sort options are ignored. */ async findOne(options = {}) { const { q, sort, select, populate, lean } = options; const filterOptions = this.extractFilters(options); const query = this.buildBaseQuery(q, filterOptions); let findQuery = this.model.findOne(query); const sortQuery = this.parseSortString(sort); if (Object.keys(sortQuery).length > 0) { findQuery = findQuery.sort(sortQuery); } findQuery = this.applyCommonModifiers(findQuery, select, populate, lean); return findQuery.exec(); } /** * Returns true if at least one document matches the search/filter options. */ async exists(options = {}) { const { q, ...filterOptions } = options; const query = this.buildBaseQuery(q, filterOptions); const result = await this.model.exists(query); return result !== null; } async countWithOptions(options = {}) { const { q, ...filterOptions } = options; const query = this.buildBaseQuery(q, filterOptions); return this.model.countDocuments(query); } definePreset(name, options) { this.presets.set(name, { ...options }); } getPreset(name) { return this.presets.get(name); } hasPreset(name) { return this.presets.has(name); } deletePreset(name) { return this.presets.delete(name); } listPresets() { return Array.from(this.presets.keys()); } /** * Looks up a preset and merges it with overrides. Overrides take precedence; * when both preset and override hold a plain object for the same key (e.g. an * operator filter `{ gte: 10 }`), the two objects are merged rather than the * preset's value being wholly replaced — so `{ gte: 10 }` + `{ lte: 100 }` * yields `{ gte: 10, lte: 100 }`. */ resolvePreset(presetName, overrides) { const preset = this.presets.get(presetName); if (!preset) { throw new Error(`Preset "${presetName}" not found. Available presets: ${this.listPresets().join(", ") || "none"}`); } const merged = { ...preset }; for (const key of Object.keys(overrides)) { const presetValue = preset[key]; const overrideValue = overrides[key]; if (this.isPlainObject(presetValue) && this.isPlainObject(overrideValue)) { merged[key] = { ...presetValue, ...overrideValue }; } else { merged[key] = overrideValue; } } return merged; } isPlainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date); } async findWithPreset(presetName, overrides = {}) { return this.findWithOptions(this.resolvePreset(presetName, overrides)); } async countWithPreset(presetName, overrides = {}) { return this.countWithOptions(this.resolvePreset(presetName, overrides)); } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { QueryToolkit });