|
| 1 | +import mongoose from "mongoose"; |
| 2 | + |
| 3 | +const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i; |
| 4 | + |
1 | 5 | const parsePositiveInt = (value) => { |
2 | 6 | if (value === undefined || value === null || value === "") return null; |
3 | 7 | const num = Number(value); |
@@ -54,19 +58,206 @@ const buildSearchFilter = (searchTerm, searchFields = []) => { |
54 | 58 | }; |
55 | 59 | }; |
56 | 60 |
|
57 | | -const applyPopulate = (query, req, allowedPopulate = [], defaultPopulate = []) => { |
| 61 | +const getPopulatePaths = (req, allowedPopulate = [], defaultPopulate = []) => { |
58 | 62 | const raw = req.query.populate; |
59 | 63 | const requested = typeof raw === "string" ? parseCommaList(raw) : []; |
60 | | - const populateFields = new Set([ |
| 64 | + return [...new Set([ |
61 | 65 | ...defaultPopulate, |
62 | 66 | ...requested.filter((path) => allowedPopulate.includes(path)), |
63 | | - ]); |
64 | | - for (const path of populateFields) { |
| 67 | + ])]; |
| 68 | +}; |
| 69 | + |
| 70 | +const applyPopulate = (query, req, allowedPopulate = [], defaultPopulate = []) => { |
| 71 | + const populatePaths = getPopulatePaths(req, allowedPopulate, defaultPopulate); |
| 72 | + for (const path of populatePaths) { |
65 | 73 | query = query.populate(path); |
66 | 74 | } |
67 | 75 | return query; |
68 | 76 | }; |
69 | 77 |
|
| 78 | +const getFieldTypeForFilter = (Model, field) => { |
| 79 | + const schemaPath = Model?.schema?.path(field); |
| 80 | + if (!schemaPath) return null; |
| 81 | + |
| 82 | + if (schemaPath.instance !== "Array") { |
| 83 | + return schemaPath.instance; |
| 84 | + } |
| 85 | + |
| 86 | + const rawArrayType = Array.isArray(schemaPath.options?.type) |
| 87 | + ? schemaPath.options.type[0] |
| 88 | + : schemaPath.options?.type; |
| 89 | + const itemType = rawArrayType?.type || rawArrayType; |
| 90 | + |
| 91 | + if (itemType === mongoose.Schema.Types.ObjectId) return "ObjectId"; |
| 92 | + if (itemType === Number) return "Number"; |
| 93 | + if (itemType === Boolean) return "Boolean"; |
| 94 | + if (itemType === Date) return "Date"; |
| 95 | + if (itemType === String) return "String"; |
| 96 | + return "Array"; |
| 97 | +}; |
| 98 | + |
| 99 | +const castFilterScalar = (value, fieldType) => { |
| 100 | + if (value === undefined || value === null) return value; |
| 101 | + |
| 102 | + switch (fieldType) { |
| 103 | + case "ObjectId": |
| 104 | + if (value instanceof mongoose.Types.ObjectId) return value; |
| 105 | + if (typeof value === "string" && OBJECT_ID_PATTERN.test(value)) { |
| 106 | + return new mongoose.Types.ObjectId(value); |
| 107 | + } |
| 108 | + return value; |
| 109 | + case "Number": |
| 110 | + if (typeof value === "number") return value; |
| 111 | + if (typeof value === "string" && value.trim() !== "") { |
| 112 | + const num = Number(value); |
| 113 | + if (Number.isFinite(num)) return num; |
| 114 | + } |
| 115 | + return value; |
| 116 | + case "Boolean": |
| 117 | + if (typeof value === "boolean") return value; |
| 118 | + if (typeof value === "string") { |
| 119 | + const normalized = value.trim().toLowerCase(); |
| 120 | + if (normalized === "true") return true; |
| 121 | + if (normalized === "false") return false; |
| 122 | + } |
| 123 | + return value; |
| 124 | + case "Date": |
| 125 | + if (value instanceof Date) return value; |
| 126 | + if (typeof value === "string" || typeof value === "number") { |
| 127 | + const date = new Date(value); |
| 128 | + if (!Number.isNaN(date.getTime())) return date; |
| 129 | + } |
| 130 | + return value; |
| 131 | + default: |
| 132 | + return value; |
| 133 | + } |
| 134 | +}; |
| 135 | + |
| 136 | +const castFiltersForAggregation = (Model, filters = {}) => { |
| 137 | + const casted = {}; |
| 138 | + |
| 139 | + for (const [field, rawValue] of Object.entries(filters)) { |
| 140 | + const fieldType = getFieldTypeForFilter(Model, field); |
| 141 | + |
| 142 | + if (rawValue && typeof rawValue === "object" && !Array.isArray(rawValue)) { |
| 143 | + const castedOperators = {}; |
| 144 | + for (const [operator, operatorValue] of Object.entries(rawValue)) { |
| 145 | + if (Array.isArray(operatorValue)) { |
| 146 | + castedOperators[operator] = operatorValue.map((value) => |
| 147 | + castFilterScalar(value, fieldType) |
| 148 | + ); |
| 149 | + } else { |
| 150 | + castedOperators[operator] = castFilterScalar(operatorValue, fieldType); |
| 151 | + } |
| 152 | + } |
| 153 | + casted[field] = castedOperators; |
| 154 | + continue; |
| 155 | + } |
| 156 | + |
| 157 | + casted[field] = castFilterScalar(rawValue, fieldType); |
| 158 | + } |
| 159 | + |
| 160 | + return casted; |
| 161 | +}; |
| 162 | + |
| 163 | +const buildSortObject = (sortValue, fallbackSort) => { |
| 164 | + const raw = typeof sortValue === "string" && sortValue.trim() |
| 165 | + ? sortValue |
| 166 | + : fallbackSort; |
| 167 | + |
| 168 | + const sort = {}; |
| 169 | + const tokens = String(raw || "") |
| 170 | + .split(/[,\s]+/) |
| 171 | + .map((token) => token.trim()) |
| 172 | + .filter(Boolean); |
| 173 | + |
| 174 | + for (const token of tokens) { |
| 175 | + if (token.startsWith("-")) { |
| 176 | + const field = token.slice(1); |
| 177 | + if (field) sort[field] = -1; |
| 178 | + continue; |
| 179 | + } |
| 180 | + if (token.startsWith("+")) { |
| 181 | + const field = token.slice(1); |
| 182 | + if (field) sort[field] = 1; |
| 183 | + continue; |
| 184 | + } |
| 185 | + sort[token] = 1; |
| 186 | + } |
| 187 | + |
| 188 | + if (!Object.keys(sort).length) { |
| 189 | + sort.createdAt = -1; |
| 190 | + } |
| 191 | + |
| 192 | + return sort; |
| 193 | +}; |
| 194 | + |
| 195 | +const isTextIndexMissingError = (error) => { |
| 196 | + const message = String(error?.message || "").toLowerCase(); |
| 197 | + return ( |
| 198 | + error?.codeName === "IndexNotFound" || |
| 199 | + message.includes("text index required") || |
| 200 | + message.includes("index not found for $text") |
| 201 | + ); |
| 202 | +}; |
| 203 | + |
| 204 | +const buildListPipeline = ({ |
| 205 | + Model, |
| 206 | + filters, |
| 207 | + searchTerm, |
| 208 | + searchFields, |
| 209 | + sortValue, |
| 210 | + defaultSort, |
| 211 | + skip, |
| 212 | + limit, |
| 213 | + preferTextSearch, |
| 214 | +}) => { |
| 215 | + const pipeline = []; |
| 216 | + const castedFilters = castFiltersForAggregation(Model, filters); |
| 217 | + |
| 218 | + if (Object.keys(castedFilters).length) { |
| 219 | + pipeline.push({ $match: castedFilters }); |
| 220 | + } |
| 221 | + |
| 222 | + const trimmedSearch = (searchTerm || "").trim(); |
| 223 | + const canUseTextSearch = |
| 224 | + Boolean(trimmedSearch) && Array.isArray(searchFields) && searchFields.length; |
| 225 | + const shouldUseTextSearch = |
| 226 | + canUseTextSearch && preferTextSearch && trimmedSearch.length >= 3; |
| 227 | + |
| 228 | + if (canUseTextSearch) { |
| 229 | + if (shouldUseTextSearch) { |
| 230 | + pipeline.push({ $match: { $text: { $search: trimmedSearch } } }); |
| 231 | + pipeline.push({ $addFields: { _searchScore: { $meta: "textScore" } } }); |
| 232 | + } else { |
| 233 | + const searchFilter = buildSearchFilter(trimmedSearch, searchFields); |
| 234 | + if (searchFilter) { |
| 235 | + pipeline.push({ $match: searchFilter }); |
| 236 | + } |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + const hasExplicitSort = typeof sortValue === "string" && sortValue.trim(); |
| 241 | + let sortStage = buildSortObject(sortValue, defaultSort); |
| 242 | + if (shouldUseTextSearch && !hasExplicitSort) { |
| 243 | + sortStage = { _searchScore: -1, ...sortStage }; |
| 244 | + } |
| 245 | + |
| 246 | + pipeline.push({ $sort: sortStage }); |
| 247 | + |
| 248 | + if (skip) { |
| 249 | + pipeline.push({ $skip: skip }); |
| 250 | + } |
| 251 | + if (limit) { |
| 252 | + pipeline.push({ $limit: limit }); |
| 253 | + } |
| 254 | + |
| 255 | + pipeline.push({ $addFields: { id: { $toString: "$_id" } } }); |
| 256 | + pipeline.push({ $project: { __v: 0, _searchScore: 0 } }); |
| 257 | + |
| 258 | + return pipeline; |
| 259 | +}; |
| 260 | + |
70 | 261 | const pickAllowed = (payload, allowedFields) => { |
71 | 262 | if (!Array.isArray(allowedFields) || !allowedFields.length) { |
72 | 263 | return { ...payload }; |
@@ -133,19 +324,46 @@ export const buildCrudController = ( |
133 | 324 | } |
134 | 325 |
|
135 | 326 | const searchTerm = (req.query.q || req.query.search || "").trim(); |
136 | | - const searchFilter = buildSearchFilter(searchTerm, searchFields); |
| 327 | + const populatePaths = getPopulatePaths( |
| 328 | + req, |
| 329 | + allowedPopulate, |
| 330 | + defaultPopulate |
| 331 | + ); |
137 | 332 |
|
138 | | - let query = Model.find(filters); |
139 | | - if (searchFilter) { |
140 | | - query = query.find(searchFilter); |
141 | | - } |
| 333 | + const runListAggregation = async (preferTextSearch) => { |
| 334 | + const pipeline = buildListPipeline({ |
| 335 | + Model, |
| 336 | + filters, |
| 337 | + searchTerm, |
| 338 | + searchFields, |
| 339 | + sortValue: req.query.sort, |
| 340 | + defaultSort, |
| 341 | + skip, |
| 342 | + limit, |
| 343 | + preferTextSearch, |
| 344 | + }); |
142 | 345 |
|
143 | | - if (skip) query = query.skip(skip); |
144 | | - if (limit) query = query.limit(limit); |
145 | | - query = query.sort(req.query.sort || defaultSort); |
146 | | - query = applyPopulate(query, req, allowedPopulate, defaultPopulate); |
| 346 | + let docs = await Model.aggregate(pipeline).exec(); |
| 347 | + if (populatePaths.length) { |
| 348 | + docs = await Model.populate(docs, populatePaths); |
| 349 | + } |
| 350 | + return docs; |
| 351 | + }; |
| 352 | + |
| 353 | + let docs; |
| 354 | + try { |
| 355 | + docs = await runListAggregation(true); |
| 356 | + if (searchTerm && searchFields.length && !docs.length) { |
| 357 | + docs = await runListAggregation(false); |
| 358 | + } |
| 359 | + } catch (error) { |
| 360 | + if (searchTerm && searchFields.length && isTextIndexMissingError(error)) { |
| 361 | + docs = await runListAggregation(false); |
| 362 | + } else { |
| 363 | + throw error; |
| 364 | + } |
| 365 | + } |
147 | 366 |
|
148 | | - const docs = await query.exec(); |
149 | 367 | res.json(docs); |
150 | 368 | } catch (error) { |
151 | 369 | next(error); |
|
0 commit comments