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
JavaScript
"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
});