dobo 2.38.0 → 2.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/.bootorder +0 -0
  2. package/.github/FUNDING.yml +0 -0
  3. package/.github/workflows/repo-lockdown.yml +0 -0
  4. package/.jsdoc.conf.json +0 -0
  5. package/LICENSE +0 -0
  6. package/README.md +0 -0
  7. package/docs/Dobo.html +0 -0
  8. package/docs/DoboAction.html +0 -0
  9. package/docs/DoboAdapter.html +0 -0
  10. package/docs/DoboConnection.html +0 -0
  11. package/docs/DoboFeature.html +0 -0
  12. package/docs/DoboModel.html +0 -0
  13. package/docs/data/search.json +0 -0
  14. package/docs/extend_bajo_hook-docs.js.html +0 -0
  15. package/docs/external-Tools.html +0 -0
  16. package/docs/fonts/Inconsolata-Regular.ttf +0 -0
  17. package/docs/fonts/OpenSans-Regular.ttf +0 -0
  18. package/docs/fonts/WorkSans-Bold.ttf +0 -0
  19. package/docs/global.html +0 -0
  20. package/docs/index.html +0 -0
  21. package/docs/index.js.html +0 -0
  22. package/docs/lib_factory_action.js.html +0 -0
  23. package/docs/lib_factory_adapter.js.html +0 -0
  24. package/docs/lib_factory_connection.js.html +0 -0
  25. package/docs/lib_factory_feature.js.html +0 -0
  26. package/docs/lib_factory_model.js.html +0 -0
  27. package/docs/lib_factory_model_build.js.html +0 -0
  28. package/docs/lib_factory_model_clear-record.js.html +0 -0
  29. package/docs/lib_factory_model_count-record.js.html +0 -0
  30. package/docs/lib_factory_model_create-record.js.html +0 -0
  31. package/docs/lib_factory_model_drop.js.html +0 -0
  32. package/docs/lib_factory_model_exists.js.html +0 -0
  33. package/docs/lib_factory_model_find-all-record.js.html +0 -0
  34. package/docs/lib_factory_model_find-one-record.js.html +0 -0
  35. package/docs/lib_factory_model_find-record.js.html +0 -0
  36. package/docs/lib_factory_model_get-record.js.html +0 -0
  37. package/docs/lib_factory_model_helper.js.html +0 -0
  38. package/docs/lib_factory_model_remove-record.js.html +0 -0
  39. package/docs/lib_factory_model_sanitize-body.js.html +0 -0
  40. package/docs/lib_factory_model_sanitize-fixture.js.html +0 -0
  41. package/docs/lib_factory_model_sanitize-record.js.html +0 -0
  42. package/docs/lib_factory_model_update-record.js.html +0 -0
  43. package/docs/lib_factory_model_upsert-record.js.html +0 -0
  44. package/docs/lib_factory_model_validate.js.html +0 -0
  45. package/docs/lib_helper.js.html +0 -0
  46. package/docs/module-Helper.html +0 -0
  47. package/docs/module-Helper_Model.html +0 -0
  48. package/docs/module-Hook.html +0 -0
  49. package/docs/scripts/core.js +0 -0
  50. package/docs/scripts/core.min.js +0 -0
  51. package/docs/scripts/resize.js +0 -0
  52. package/docs/scripts/search.js +0 -0
  53. package/docs/scripts/search.min.js +5 -5
  54. package/docs/scripts/third-party/Apache-License-2.0.txt +0 -0
  55. package/docs/scripts/third-party/fuse.js +0 -0
  56. package/docs/scripts/third-party/hljs-line-num-original.js +0 -0
  57. package/docs/scripts/third-party/hljs-line-num.js +0 -0
  58. package/docs/scripts/third-party/hljs-original.js +0 -0
  59. package/docs/scripts/third-party/hljs.js +0 -0
  60. package/docs/scripts/third-party/popper.js +0 -0
  61. package/docs/scripts/third-party/tippy.js +0 -0
  62. package/docs/scripts/third-party/tocbot.js +0 -0
  63. package/docs/scripts/third-party/tocbot.min.js +0 -0
  64. package/docs/static/bitcoin.jpeg +0 -0
  65. package/docs/static/home.md +0 -0
  66. package/docs/static/logo-ecosystem.png +0 -0
  67. package/docs/static/logo.png +0 -0
  68. package/docs/styles/clean-jsdoc-theme-base.css +0 -0
  69. package/docs/styles/clean-jsdoc-theme-dark.css +0 -0
  70. package/docs/styles/clean-jsdoc-theme-light.css +0 -0
  71. package/docs/styles/clean-jsdoc-theme-scrollbar.css +0 -0
  72. package/docs/styles/clean-jsdoc-theme-without-scrollbar.min.css +0 -0
  73. package/docs/styles/clean-jsdoc-theme.min.css +0 -0
  74. package/extend/bajo/hook.js +236 -236
  75. package/extend/bajo/intl/en-US.json +0 -0
  76. package/extend/bajo/intl/id.json +0 -0
  77. package/extend/bajoCli/applet/aggregate.js +0 -0
  78. package/extend/bajoCli/applet/clear-record.js +0 -0
  79. package/extend/bajoCli/applet/connection.js +0 -0
  80. package/extend/bajoCli/applet/count-record.js +0 -0
  81. package/extend/bajoCli/applet/create-record.js +0 -0
  82. package/extend/bajoCli/applet/find-record.js +0 -0
  83. package/extend/bajoCli/applet/get-record.js +0 -0
  84. package/extend/bajoCli/applet/histogram.js +0 -0
  85. package/extend/bajoCli/applet/lib/post-process.js +0 -0
  86. package/extend/bajoCli/applet/model.js +0 -0
  87. package/extend/bajoCli/applet/rebuild-model.js +0 -0
  88. package/extend/bajoCli/applet/remove-record.js +0 -0
  89. package/extend/bajoCli/applet/update-record.js +0 -0
  90. package/extend/bajoCli/applet.js +0 -0
  91. package/extend/dobo/adapter/memory.js +175 -175
  92. package/extend/dobo/feature/created-at.js +0 -0
  93. package/extend/dobo/feature/dt.js +0 -0
  94. package/extend/dobo/feature/image.js +0 -0
  95. package/extend/dobo/feature/immutable.js +0 -0
  96. package/extend/dobo/feature/removed-at.js +0 -0
  97. package/extend/dobo/feature/unique.js +0 -0
  98. package/extend/dobo/feature/updated-at.js +0 -0
  99. package/extend/waibuMpa/route/attachment/@model/@id/@field/@file.js +0 -0
  100. package/extend/waibuStatic/virtual.json +0 -0
  101. package/index.js +0 -0
  102. package/lib/factory/action.js +287 -287
  103. package/lib/factory/adapter.js +1055 -1055
  104. package/lib/factory/connection.js +109 -109
  105. package/lib/factory/feature.js +52 -52
  106. package/lib/factory/model/aggregate.js +23 -23
  107. package/lib/factory/model/build.js +25 -25
  108. package/lib/factory/model/bulk-create-record.js +36 -36
  109. package/lib/factory/model/clear-record.js +30 -30
  110. package/lib/factory/model/count-record.js +42 -42
  111. package/lib/factory/model/create-attachment.js +36 -36
  112. package/lib/factory/model/create-record.js +49 -49
  113. package/lib/factory/model/drop.js +26 -26
  114. package/lib/factory/model/exists.js +26 -26
  115. package/lib/factory/model/find-all-record.js +112 -112
  116. package/lib/factory/model/find-attachment.js +28 -28
  117. package/lib/factory/model/find-one-record.js +38 -38
  118. package/lib/factory/model/find-record.js +82 -82
  119. package/lib/factory/model/get-attachment.js +15 -15
  120. package/lib/factory/model/get-record.js +52 -52
  121. package/lib/factory/model/helper.js +575 -576
  122. package/lib/factory/model/histogram.js +23 -23
  123. package/lib/factory/model/list-attachment.js +40 -40
  124. package/lib/factory/model/load-fixtures.js +63 -63
  125. package/lib/factory/model/remove-attachment.js +24 -24
  126. package/lib/factory/model/remove-record.js +44 -44
  127. package/lib/factory/model/sanitize-body.js +57 -57
  128. package/lib/factory/model/sanitize-fixture.js +56 -56
  129. package/lib/factory/model/sanitize-record.js +80 -80
  130. package/lib/factory/model/transaction.js +15 -15
  131. package/lib/factory/model/update-attachment.js +9 -9
  132. package/lib/factory/model/update-record.js +58 -58
  133. package/lib/factory/model/upsert-record.js +71 -71
  134. package/lib/factory/model/validate.js +0 -0
  135. package/lib/factory/model.js +503 -503
  136. package/lib/helper.js +463 -463
  137. package/package.json +1 -1
  138. package/test/e2e/_run.js +12 -12
  139. package/test/e2e/e2e-factory-process.test.js +25 -25
  140. package/test/e2e/e2e-model-adapter-process.test.js +52 -52
  141. package/test/integration/aspect-01-start-connections.test.js +33 -33
  142. package/test/integration/aspect-02-query-regex.test.js +34 -34
  143. package/test/integration/aspect-03-model-adapter-flow.test.js +88 -88
  144. package/test/unit/_stub.js +244 -244
  145. package/test/unit/dobo-action.test.js +58 -58
  146. package/test/unit/dobo-connection.test.js +37 -37
  147. package/test/unit/dobo-core.test.js +136 -136
  148. package/test/unit/dobo-feature.test.js +25 -25
  149. package/test/unit/dobo-model.test.js +115 -115
  150. package/test/unit/helper-sanitize-all.test.js +40 -40
  151. package/test/unit/helper-sanitize-ref.test.js +64 -64
  152. package/wiki/APPLETS.md +0 -0
  153. package/wiki/CHANGES.md +477 -469
  154. package/wiki/CONFIG.md +0 -0
  155. package/wiki/CONTRIBUTING.md +0 -0
  156. package/wiki/DEV-GUIDE.md +0 -0
  157. package/wiki/ECOSYSTEM.md +0 -0
  158. package/wiki/GETTING-STARTED.md +0 -0
  159. package/wiki/QUERY-LANGUAGE.md +0 -0
  160. package/wiki/USER-GUIDE.md +0 -0
@@ -1,576 +1,575 @@
1
- import path from 'path'
2
-
3
- /**
4
- * Helper functions for model operations in the Dobo framework.
5
- *
6
- * @module Helper/Model
7
- */
8
-
9
- /**
10
- * Clone options object and omit some keys to avoid reference issues
11
- *
12
- * @method
13
- * @name cloneOptions
14
- * @memberof module:Helper/Model
15
- * @param {DoboModel.TOptions} [options={}]
16
- * @returns {DoboModel.TOptions} Cloned options object
17
- */
18
- export function cloneOptions (options = {}) {
19
- const { cloneDeep, omit } = this.app.lib._
20
- const omittedOptionsKeys = ['req', 'reply', 'trx']
21
- const nOptions = cloneDeep(omit(options, omittedOptionsKeys))
22
- for (const key of omittedOptionsKeys) {
23
- nOptions[key] = options[key]
24
- }
25
- return nOptions
26
- }
27
-
28
- /**
29
- * Executes a hook function with the provided name and arguments.
30
- * @async
31
- * @memberof module:Helper/Model
32
- * @method
33
- * @name execHook
34
- * @param {string} name - The name of the hook to execute.
35
- * @param {...any} args - Arguments to pass to the hook function.
36
- * @returns {Promise<void>}
37
- */
38
- export async function execHook (name, ...args) {
39
- const { runHook } = this.app.bajo
40
- const { camelCase, last, kebabCase } = this.app.lib._
41
- const { noHook } = last(args)
42
- const { ns } = this.app.dobo
43
- let [prefix, ...action] = kebabCase(name).split('-')
44
- action = camelCase(action.join(' '))
45
- if (!noHook) {
46
- if (prefix === 'before') await runHook(`${ns}:beforeAction`, action, this.name, ...args)
47
- await runHook(`${ns}:${name}`, this.name, ...args)
48
- if (prefix === 'after') await runHook(`${ns}:afterAction`, action, this.name, ...args)
49
- if (prefix === 'before') await runHook(`${ns}.${camelCase(this.name)}:beforeAction`, action, ...args)
50
- await runHook(`${ns}.${camelCase(this.name)}:${name}`, ...args)
51
- if (prefix === 'after') await runHook(`${ns}.${camelCase(this.name)}:afterAction`, action, ...args)
52
- }
53
- }
54
-
55
- /**
56
- * Executes a model hook function with the provided name and arguments.
57
- * @async
58
- * @memberof module:Helper/Model
59
- * @method
60
- * @name execModelHook
61
- * @param {string} name - The name of the model hook to execute.
62
- * @param {...any} args - Arguments to pass to the model hook function.
63
- * @returns {Promise<void>}
64
- */
65
- export async function execModelHook (name, ...args) {
66
- const { last } = this.app.lib._
67
- const { runModelHook } = this.app.dobo
68
- const { noModelHook } = last(args)
69
- if (!noModelHook) await runModelHook(this, name, ...args)
70
- }
71
-
72
- /**
73
- * Executes a dynamic hook function with the provided name and arguments.
74
- * @async
75
- * @memberof module:Helper/Model
76
- * @method
77
- * @name execDynHook
78
- * @param {string} name - The name of the dynamic hook to execute.
79
- * @param {...any} args - Arguments to pass to the dynamic hook function.
80
- * @returns {Promise<void>}
81
- */
82
- export async function execDynHook (name, ...args) {
83
- const { last, orderBy } = this.app.lib._
84
- const opts = last(args)
85
- const results = []
86
- if (!opts.noDynHook) {
87
- const hooks = orderBy((opts.dynHooks ?? []).filter(hook => hook.name === name), ['level'])
88
- for (const hook of hooks) {
89
- if (hook.noWait) hook.handler.call(this, ...args)
90
- else await hook.handler.call(this, ...args)
91
- }
92
- }
93
- return results
94
- }
95
-
96
- /**
97
- * Executes validation on the provided body with the given options.
98
- * @async
99
- * @memberof module:Helper/Model
100
- * @method
101
- * @name execValidation
102
- * @param {Object} body - The data to validate.
103
- * @param {DoboModel.TOptions} [options={}] - Validation options.
104
- * @returns {Promise<Object>} The result of the validation.
105
- */
106
- export async function execValidation (body, options = {}) {
107
- const { uniq } = this.app.lib._
108
- const { validation = {} } = options
109
- const fields = uniq([...Object.keys(body), ...(options.fields ?? [])])
110
- await execHook.call(this, 'beforeRecordValidation', body, options)
111
- await execModelHook.call(this, 'beforeRecordValidation', body, options)
112
- const result = await this.validate(body, validation, { fields, ...options })
113
- await execModelHook.call(this, 'afterRecordValidation', body, result, options)
114
- await execHook.call(this, 'afterRecordValidation', body, result, options)
115
- return result
116
- }
117
-
118
- /**
119
- * Prepares and returns the filter and options for a given action.
120
- * @async
121
- * @memberof module:Helper/Model
122
- * @method
123
- * @name getFilterAndOptions
124
- * @param {DoboModel.TFilter} filter - The filter criteria.
125
- * @param {DoboModel.TOptions} options - The options for the action.
126
- * @param {string} action - The action being performed.
127
- * @returns {Promise<{filter: DoboModel.TFilter, options: DoboModel.TOptions}>} The prepared filter and options.
128
- */
129
- export async function getFilterAndOptions (filter = {}, options = {}, action) {
130
- const { cloneDeep } = this.app.lib._
131
- const nFilter = cloneDeep(filter || {})
132
- const nOptions = cloneOptions.call(this, options)
133
- if (options.noMagic) {
134
- nOptions.noHook = true
135
- nOptions.noDynHook = true
136
- nOptions.noValidation = true
137
- nOptions.noCache = true
138
- nOptions.throwNotFound = false
139
- delete options.noMagic
140
- delete nOptions.noMagic
141
- }
142
- nOptions.action = action
143
- nOptions.dataOnly = false
144
- nOptions.truncateString = nOptions.truncateString ?? false
145
- nOptions.throwNotFound = nOptions.throwNotFound ?? true
146
- nFilter.orgQuery = nFilter.query
147
- nFilter.orgSearch = nFilter.search
148
- nFilter.query = buildFilterQuery.call(this, nFilter) ?? {}
149
- nFilter.search = buildFilterSearch.call(this, nFilter) ?? {}
150
- const { limit, page, skip, sort } = preparePagination.call(this, nFilter, nOptions)
151
- nFilter.limit = limit
152
- nFilter.page = page
153
- nFilter.skip = skip
154
- nFilter.sort = sort
155
- if (nOptions.queryHandler) {
156
- const scope = nOptions.req ? this.app[nOptions.req.ns] : this.plugin
157
- nFilter.query = await options.queryHandler.call(scope, nFilter.query, nOptions.req)
158
- }
159
- return { filter: nFilter, options: nOptions }
160
- }
161
-
162
- /**
163
- * Handles a request for a given action trigger.
164
- * @async
165
- * @memberof module:Helper/Model
166
- * @method
167
- * @name handleReq
168
- * @param {string|number} id - The ID of the record.
169
- * @param {string} trigger - The action trigger (e.g., 'created', 'updated', 'removed').
170
- * @param {DoboModel.TOptions} [options={}] - Additional options for handling the request.
171
- * @returns {Promise<void>}
172
- */
173
- export async function handleReq (id, trigger, options = {}) {
174
- const { upperFirst } = this.app.lib._
175
- if (options.req) {
176
- if (options.req.file && trigger !== 'removed') await handleAttachmentUpload.call(this, id, trigger, options)
177
- if (options.req.flash && !options.noFlash) options.req.flash('notify', options.req.t(`record${upperFirst(trigger)}`))
178
- }
179
- }
180
-
181
- /**
182
- * Merges attachment information into the given record.
183
- * @async
184
- * @memberof module:Helper/Model
185
- * @method
186
- * @name mergeAttachmentInfo
187
- * @param {Object} rec - The record to merge attachment info into.
188
- * @param {string} source - The source file path of the attachment.
189
- * @param {Object} options - Additional options including mimeType, stats, and fullPath.
190
- * @returns {Promise<void>}
191
- */
192
- export async function mergeAttachmentInfo (rec, source, options = {}) {
193
- if (!this.app.waibu) return
194
- const { mimeType, stats, fullPath } = options
195
- const { importPkg } = this.app.bajo
196
- const { fs } = this.app.lib
197
- const { pick } = this.app.lib._
198
- const mime = await importPkg('waibu:mime')
199
-
200
- if (mimeType) rec.mimeType = mime.getType(rec.file)
201
- if (fullPath) rec.fullPath = source
202
- if (stats) {
203
- const s = fs.statSync(source)
204
- rec.stats = pick(s, ['size', 'atime', 'ctime', 'mtime'])
205
- }
206
- }
207
-
208
- /**
209
- * Gets the attachment path for a given record and field.
210
- * @async
211
- * @memberof module:Helper/Model
212
- * @method
213
- * @name getAttachmentPath
214
- * @param {string|number} id - The ID of the record.
215
- * @param {string} field - The field name of the attachment.
216
- * @param {string} file - The file name of the attachment.
217
- * @param {Object} options - Additional options, including dirOnly.
218
- * @returns {Promise<string>} The path to the attachment.
219
- */
220
- export async function getAttachmentPath (id, field, file, options = {}) {
221
- const { fs } = this.app.lib
222
- const dir = `${this.app.getPluginDataDir(this.app.dobo.ns)}/attachment/${this.name}/${id}`
223
- if (options.dirOnly) return dir
224
- const path = field ? `${dir}/${field}/${file}` : `${dir}/${file}`
225
- if (!fs.existsSync(path)) throw this.app.dobo.error('notFound')
226
- return path
227
- }
228
-
229
- /**
230
- * Copies attachments for a given record.
231
- * @name copyAttachment
232
- * @async
233
- * @memberof module:Helper/Model
234
- * @method
235
- * @param {string|number} id - The ID of the record.
236
- * @param {Object} options - Additional options for copying attachments.
237
- * @returns {Promise<Array>} The copied attachment records.
238
- */
239
- export async function copyAttachment (id, options = {}) {
240
- if (!this.app.waibu) return
241
- if (!this.options.attachment) return
242
- const { fs } = this.app.lib
243
- const { req, setField, setFile, mimeType, stats } = options
244
- const { dir, files } = await this.app.waibu.getUploadedFiles(req.id, false, true)
245
- const result = []
246
- if (files.length === 0) return result
247
- for (const f of files) {
248
- let [field, ...parts] = path.basename(f).split('@')
249
- if (parts.length === 0) continue
250
- field = setField ?? field
251
- const file = setFile ?? parts.join('@')
252
- const opts = { source: f, field, file, mimeType, stats, req }
253
- const rec = await this.createAttachment(id, opts)
254
- if (!rec) continue
255
- delete rec.dir
256
- result.push(rec)
257
- if (setField || setFile) break
258
- }
259
- fs.removeSync(dir)
260
- return result
261
- }
262
-
263
- /**
264
- * Handles attachment uploads for a given record and trigger.
265
- * @async
266
- * @memberof module:Helper/Model
267
- * @method
268
- * @name handleAttachmentUpload
269
- * @param {string|number} id - The ID of the record.
270
- * @param {string} trigger - The action trigger (e.g., 'added', 'removed').
271
- * @param {Object} options - Additional options for handling the upload.
272
- * @returns {Promise<void>}
273
- */
274
- export async function handleAttachmentUpload (id, trigger, options = {}) {
275
- if (!this.options.attachment) return
276
- const { fs } = this.app.lib
277
- const { req, mimeType, stats, setFile, setField } = options
278
- if (trigger === 'removed') {
279
- const dir = `${this.app.getPluginDataDir(this.app.dobo.ns)}/attachment/${this.name}/${id}`
280
- await fs.remove(dir)
281
- return
282
- }
283
- return copyAttachment.call(this, id, { req, mimeType, stats, setFile, setField })
284
- }
285
-
286
- /**
287
- * Gets reference records for the given records.
288
- * @async
289
- * @memberof module:Helper/Model
290
- * @method
291
- * @name getRefs
292
- * @param {Array<Object>} records - The records to get references for.
293
- * @param {Object} options - Additional options for fetching references.
294
- * @returns {Promise<void>}
295
- */
296
- export async function getRefs (records = [], options = {}) {
297
- const { isSet } = this.app.lib.aneka
298
- const { uniq, without, get } = this.app.lib._
299
- const { parseQuery } = this.app.dobo
300
- const props = this.getNonVirtualProperties().filter(p => isSet(p.ref) && !(options.hidden ?? []).includes(p.name))
301
- options.refs = options.refs ?? []
302
- if (props.length > 0) {
303
- for (const prop of props) {
304
- for (const key in prop.ref) {
305
- try {
306
- if (records.length === 0) return
307
- const isValues = Array.isArray(records[0][prop.name])
308
- if (get(records, `0._ref.${key}`)) return
309
- const ref = prop.ref[key]
310
- const rModel = this.app.dobo.getModel(ref.model, true)
311
- if (!rModel) return
312
- let matches = []
313
- for (const rec of records) {
314
- const items = isValues ? [...(rec[prop.name] ?? [])] : (rec[prop.name] ? [rec[prop.name]] : [])
315
- matches.push(...items.map(item => prop.name === 'id' ? rModel.sanitizeId(item) : item))
316
- }
317
- matches = uniq(without(matches, undefined, null, NaN)).map(i => i + '')
318
- let query = {}
319
- query[ref.field] = { $in: matches }
320
-
321
- const siteIdProp = this.properties.find(item => item.name === 'siteId')
322
- const siteIdRProp = rModel.properties.find(item => item.name === 'siteId')
323
- if (siteIdProp && siteIdRProp) query.siteId = records[0].siteId
324
-
325
- if (ref.query) query = { $and: [query, parseQuery(ref.query, rModel)] }
326
- const filter = { query, limit: matches.length }
327
- if (!((typeof options.refs === 'string' && ['*', 'all'].includes(options.refs)) || options.refs.includes(key))) return
328
- if (ref.fields.length === 0) return
329
- const { fmt, req } = options
330
- const fields = [...ref.fields]
331
- if (!fields.includes(prop.name)) fields.push(prop.name)
332
- const rOptions = { dataOnly: true, refs: [], fmt, req, fields }
333
- const results = await rModel.findRecord(filter, rOptions)
334
- for (const i in records) {
335
- records[i]._ref = records[i]._ref ?? {}
336
- const rec = records[i]
337
- let items = isValues ? [...(rec[prop.name] ?? [])] : (rec[prop.name] ? [rec[prop.name]] : [])
338
- items = items.map(item => item + '')
339
- const res = results.filter(r => items.includes(r[ref.field] + ''))
340
- if (res.length === 0) records[i]._ref[key] = isValues ? [] : {}
341
- else records[i]._ref[key] = isValues ? res : res[0]
342
- }
343
- } catch (err) {
344
- if (this.app.bajo.config.log.level === 'trace') console.error(err)
345
- }
346
- }
347
- }
348
- }
349
- }
350
-
351
- /**
352
- * Builds a sanitized filter query for the given filter.
353
- * @memberof module:Helper/Model
354
- * @method
355
- * @name buildFilterQuery
356
- * @param {Object} filter - The filter object containing query parameters.
357
- * @returns {Object} The sanitized query object.
358
- */
359
- export function buildFilterQuery (filter = {}) {
360
- const { parseQuery } = this.app.dobo
361
- const query = parseQuery(filter.query ?? {}, this, false)
362
- return sanitizeQuery.call(this, query)
363
- }
364
-
365
- /**
366
- * Sanitizes a query object by ensuring that its fields and values conform to the model's schema.
367
- * @memberof module:Helper/Model
368
- * @method
369
- * @name sanitizeQuery
370
- * @param {Object} query - The query object to sanitize.
371
- * @param {string} parent - The parent field name, if applicable.
372
- * @returns {Object} The sanitized query object.
373
- */
374
- export function sanitizeQuery (query = {}, parent) {
375
- const { isPlainObject, isArray, find, cloneDeep } = this.app.lib._
376
- const { isSet } = this.app.lib.aneka
377
- const { dayjs } = this.app.lib
378
- const obj = cloneDeep(query)
379
- const keys = Object.keys(obj)
380
-
381
- const sanitizeField = (prop, val) => {
382
- if (!prop) return val
383
- if (val instanceof RegExp) return val
384
- if (['datetime'].includes(prop.type)) {
385
- const dt = dayjs(val)
386
- return dt.isValid() ? dt.toDate() : val
387
- } else if (['smallint', 'integer'].includes(prop.type)) return parseInt(val) || val
388
- else if (['float', 'double'].includes(prop.type)) return parseFloat(val) || val
389
- else if (['boolean'].includes(prop.type)) return !!val
390
- else if (['string', 'text'].includes(prop.type)) return val + ''
391
- return val
392
- }
393
-
394
- const sanitizeChild = (key, val, p) => {
395
- if (!isSet(val)) return val
396
- const prop = find(this.properties, { name: key.startsWith('$') ? p : key })
397
- if (!prop) return val
398
- return sanitizeField(prop, val)
399
- }
400
-
401
- keys.forEach(k => {
402
- const v = obj[k]
403
- const props = this.getProperties({ noVirtual: true })
404
- const fields = props.map(p => p.name)
405
- const prop = find(props, { name: k })
406
- if (k[0] !== '$' && !fields.includes(k)) {
407
- delete keys[k]
408
- } else {
409
- if (isPlainObject(v)) obj[k] = sanitizeQuery.call(this, v, k)
410
- else if (isArray(v)) {
411
- v.forEach((i, idx) => {
412
- if (isPlainObject(i)) obj[k][idx] = sanitizeQuery.call(this, i, k)
413
- else obj[k][idx] = sanitizeField(prop, i)
414
- })
415
- } else obj[k] = sanitizeChild(k, v, parent)
416
- }
417
- })
418
- return obj
419
- }
420
-
421
- /**
422
- * Builds a search filter from the given filter object.
423
- * @memberof module:Helper/Model
424
- * @method
425
- * @name buildFilterSearch
426
- * @param {Object} filter - The filter object containing search parameters.
427
- * @returns {Object} The constructed search filter.
428
- */
429
- export function buildFilterSearch (filter = {}) {
430
- const { isPlainObject, trim, has, uniq } = this.app.lib._
431
- const search = filter.search ?? {}
432
- let input = search
433
- if (isPlainObject(input)) return input
434
- const split = (value) => {
435
- let [field, val] = value.split(':').map(i => i.trim())
436
- if (!val) {
437
- val = field
438
- field = '*'
439
- }
440
- return { field, value: val }
441
- }
442
- input = trim(input)
443
- let items = {}
444
- if (isPlainObject(input)) items = input
445
- else if (input[0] === '{') {
446
- try {
447
- items = JSON.parse(input)
448
- } catch (err) {}
449
- } else {
450
- for (const item of input.split('+').map(i => i.trim())) {
451
- const part = split(item, ' ')
452
- if (!items[part.field]) items[part.field] = []
453
- items[part.field].push(...part.value.split(' ').filter(v => ![''].includes(v)))
454
- }
455
- }
456
- let s = {}
457
- for (const index of this.indexes.filter(i => i.type === 'fulltext')) {
458
- for (const f of index.fields) {
459
- const value = []
460
- if (typeof items[f] === 'string') items[f] = [items[f]]
461
- if (has(items, f)) value.push(...items[f])
462
- if (!s[f]) s[f] = []
463
- s[f] = uniq([...s[f], ...value])
464
- }
465
- }
466
- if (has(items, '*')) s['*'] = items['*']
467
- if (this.adapter.idField.name !== 'id') {
468
- const search = JSON.stringify(s).replaceAll('"id"', `"${this.adapter.idField.name}"`)
469
- try {
470
- s = JSON.parse(search)
471
- } catch (err) {}
472
- }
473
- return s
474
- }
475
-
476
- /**
477
- * Prepare pagination parameters for a query:
478
- * - Ensures that the limit does not exceed the maximum allowed limit.
479
- * - Ensures that the page number is within the allowed range.
480
- * - Calculates the number of records to skip based on the page and limit.
481
- * - Builds the sort order based on the provided sort input.
482
- *
483
- * @async
484
- * @memberof module:Helper/Model
485
- * @method
486
- * @name preparePagination
487
- * @param {DoboModel.TFilter} filter - The filter object containing pagination parameters.
488
- * @param {DoboModel.TOptions} options - Additional options for pagination.
489
- * @returns {Object} The prepared pagination parameters including limit, page, skip, and sort.
490
- */
491
- export function preparePagination (filter = {}, options = {}) {
492
- const { isEmpty, map, each, isPlainObject, isString, trim, keys } = this.app.lib._
493
- const { getDefaultValues, config } = this.app.dobo
494
- const { limit: defLimit, maxLimit: defMaxLimit, maxPage: defMaxPage } = getDefaultValues(options)
495
-
496
- const buildPageSkipLimit = (filter) => {
497
- let limit = parseInt(filter.limit) || defLimit
498
- if (limit === -1) limit = defMaxLimit
499
- if (limit > defMaxLimit) {
500
- options.warnings = options.warnings ?? []
501
- options.warnings.push(options.req ? options.req.t('maxLimitWarning%s%s', limit, defMaxLimit) : this.plugin.t('maxLimitWarning%s', limit, defMaxLimit))
502
- limit = defMaxLimit // TODO: notify as warning in response object
503
- }
504
- if (limit < 1) limit = 1
505
- let page = parseInt(filter.page) || 1
506
- if (page < 1) page = 1
507
- if (page > defMaxPage) throw this.plugin.error('maxPageError%s%s', page, defMaxPage)
508
- let skip = (page - 1) * limit
509
- if (filter.skip) {
510
- skip = parseInt(filter.skip) || skip
511
- page = undefined
512
- }
513
- if (skip < 0) skip = 0
514
- return { page, skip, limit }
515
- }
516
-
517
- const buildSort = (input, allowSortUnindexed) => {
518
- let sort
519
- if (isEmpty(input)) {
520
- const columns = map(this.properties ?? [], 'name')
521
- each(config.default.filter.sort, s => {
522
- const [col] = s.split(':')
523
- if (columns.includes(col)) {
524
- input = s
525
- return false
526
- }
527
- })
528
- }
529
- if (!isEmpty(input)) {
530
- if (isPlainObject(input)) sort = input
531
- else if (isString(input)) {
532
- const item = {}
533
- each(input.split('+'), text => {
534
- let [col, dir] = map(trim(text).split(':'), i => trim(i))
535
- dir = (dir ?? '').toUpperCase()
536
- dir = dir === 'DESC' ? -1 : parseInt(dir) || 1
537
- item[col] = dir / Math.abs(dir)
538
- })
539
- sort = item
540
- }
541
- const items = keys(sort)
542
- each(items, i => {
543
- if (!this.sortables.includes(i) && !allowSortUnindexed) throw this.app.dobo.error('sortOnUnindexedField%s%s', i, this.name)
544
- // if (model.fullText.fields.includes(i)) throw this.error('Can\'t sort on full-text index: \'%s@%s\'', i, model.name)
545
- })
546
- }
547
- return sort
548
- }
549
-
550
- const { page, skip, limit } = buildPageSkipLimit(filter)
551
- let sortInput = filter.sort
552
- try {
553
- sortInput = JSON.parse(sortInput)
554
- } catch (err) {
555
- }
556
- const sort = buildSort(sortInput, options.allowSortUnindexed)
557
- return { limit, page, skip, sort }
558
- }
559
-
560
- /**
561
- * Clears the cache for a specific record ID and related find operations.
562
- * @async
563
- * @memberof module:Helper/Model
564
- * @method
565
- * @name clearCache
566
- * @param {string|number} id - The ID of the record for which to clear the cache.
567
- * @returns {Promise<void>}
568
- */
569
- export async function clearCache (id) {
570
- const { clear } = this.app.bajoCache ?? {}
571
- if (!clear) return
572
- await clear({ key: `dobo|${this.name}|getRecord|${id}` })
573
- await clear({ key: `dobo|${this.name}|findRecord` })
574
- await clear({ key: `dobo|${this.name}|findAllRecord` })
575
- await clear({ key: `dobo|${this.name}|findOneRecord` })
576
- }
1
+ import path from 'path'
2
+
3
+ /**
4
+ * Helper functions for model operations in the Dobo framework.
5
+ *
6
+ * @module Helper/Model
7
+ */
8
+
9
+ /**
10
+ * Clone options object and omit some keys to avoid reference issues
11
+ *
12
+ * @method
13
+ * @name cloneOptions
14
+ * @memberof module:Helper/Model
15
+ * @param {DoboModel.TOptions} [options={}]
16
+ * @returns {DoboModel.TOptions} Cloned options object
17
+ */
18
+ export function cloneOptions (options = {}) {
19
+ const { cloneDeep, omit } = this.app.lib._
20
+ const omittedOptionsKeys = ['req', 'reply', 'trx']
21
+ const nOptions = cloneDeep(omit(options, omittedOptionsKeys))
22
+ for (const key of omittedOptionsKeys) {
23
+ nOptions[key] = options[key]
24
+ }
25
+ return nOptions
26
+ }
27
+
28
+ /**
29
+ * Executes a hook function with the provided name and arguments.
30
+ * @async
31
+ * @memberof module:Helper/Model
32
+ * @method
33
+ * @name execHook
34
+ * @param {string} name - The name of the hook to execute.
35
+ * @param {...any} args - Arguments to pass to the hook function.
36
+ * @returns {Promise<void>}
37
+ */
38
+ export async function execHook (name, ...args) {
39
+ const { runHook } = this.app.bajo
40
+ const { camelCase, last, kebabCase } = this.app.lib._
41
+ const { noHook } = last(args)
42
+ const { ns } = this.app.dobo
43
+ let [prefix, ...action] = kebabCase(name).split('-')
44
+ action = camelCase(action.join(' '))
45
+ if (!noHook) {
46
+ if (prefix === 'before') await runHook(`${ns}:beforeAction`, action, this.name, ...args)
47
+ await runHook(`${ns}:${name}`, this.name, ...args)
48
+ if (prefix === 'after') await runHook(`${ns}:afterAction`, action, this.name, ...args)
49
+ if (prefix === 'before') await runHook(`${ns}.${camelCase(this.name)}:beforeAction`, action, ...args)
50
+ await runHook(`${ns}.${camelCase(this.name)}:${name}`, ...args)
51
+ if (prefix === 'after') await runHook(`${ns}.${camelCase(this.name)}:afterAction`, action, ...args)
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Executes a model hook function with the provided name and arguments.
57
+ * @async
58
+ * @memberof module:Helper/Model
59
+ * @method
60
+ * @name execModelHook
61
+ * @param {string} name - The name of the model hook to execute.
62
+ * @param {...any} args - Arguments to pass to the model hook function.
63
+ * @returns {Promise<void>}
64
+ */
65
+ export async function execModelHook (name, ...args) {
66
+ const { last } = this.app.lib._
67
+ const { runModelHook } = this.app.dobo
68
+ const { noModelHook } = last(args)
69
+ if (!noModelHook) await runModelHook(this, name, ...args)
70
+ }
71
+
72
+ /**
73
+ * Executes a dynamic hook function with the provided name and arguments.
74
+ * @async
75
+ * @memberof module:Helper/Model
76
+ * @method
77
+ * @name execDynHook
78
+ * @param {string} name - The name of the dynamic hook to execute.
79
+ * @param {...any} args - Arguments to pass to the dynamic hook function.
80
+ * @returns {Promise<void>}
81
+ */
82
+ export async function execDynHook (name, ...args) {
83
+ const { last, orderBy } = this.app.lib._
84
+ const opts = last(args)
85
+ const results = []
86
+ if (!opts.noDynHook) {
87
+ const hooks = orderBy((opts.dynHooks ?? []).filter(hook => hook.name === name), ['level'])
88
+ for (const hook of hooks) {
89
+ if (hook.noWait) hook.handler.call(this, ...args)
90
+ else await hook.handler.call(this, ...args)
91
+ }
92
+ }
93
+ return results
94
+ }
95
+
96
+ /**
97
+ * Executes validation on the provided body with the given options.
98
+ * @async
99
+ * @memberof module:Helper/Model
100
+ * @method
101
+ * @name execValidation
102
+ * @param {Object} body - The data to validate.
103
+ * @param {DoboModel.TOptions} [options={}] - Validation options.
104
+ * @returns {Promise<Object>} The result of the validation.
105
+ */
106
+ export async function execValidation (body, options = {}) {
107
+ const { uniq } = this.app.lib._
108
+ const { validation = {} } = options
109
+ const fields = uniq([...Object.keys(body), ...(options.fields ?? [])])
110
+ await execHook.call(this, 'beforeRecordValidation', body, options)
111
+ await execModelHook.call(this, 'beforeRecordValidation', body, options)
112
+ const result = await this.validate(body, validation, { fields, ...options })
113
+ await execModelHook.call(this, 'afterRecordValidation', body, result, options)
114
+ await execHook.call(this, 'afterRecordValidation', body, result, options)
115
+ return result
116
+ }
117
+
118
+ /**
119
+ * Prepares and returns the filter and options for a given action.
120
+ * @async
121
+ * @memberof module:Helper/Model
122
+ * @method
123
+ * @name getFilterAndOptions
124
+ * @param {DoboModel.TFilter} filter - The filter criteria.
125
+ * @param {DoboModel.TOptions} options - The options for the action.
126
+ * @param {string} action - The action being performed.
127
+ * @returns {Promise<{filter: DoboModel.TFilter, options: DoboModel.TOptions}>} The prepared filter and options.
128
+ */
129
+ export async function getFilterAndOptions (filter = {}, options = {}, action) {
130
+ const { cloneDeep } = this.app.lib._
131
+ const nFilter = cloneDeep(filter || {})
132
+ const nOptions = cloneOptions.call(this, options)
133
+ if (options.noMagic) {
134
+ nOptions.noHook = true
135
+ nOptions.noDynHook = true
136
+ nOptions.noValidation = true
137
+ nOptions.noCache = true
138
+ nOptions.throwNotFound = false
139
+ delete options.noMagic
140
+ delete nOptions.noMagic
141
+ }
142
+ nOptions.action = action
143
+ nOptions.dataOnly = false
144
+ nOptions.truncateString = nOptions.truncateString ?? false
145
+ nOptions.throwNotFound = nOptions.throwNotFound ?? true
146
+ nFilter.orgQuery = nFilter.query
147
+ nFilter.orgSearch = nFilter.search
148
+ nFilter.query = buildFilterQuery.call(this, nFilter) ?? {}
149
+ nFilter.search = buildFilterSearch.call(this, nFilter) ?? {}
150
+ const { limit, page, skip, sort } = preparePagination.call(this, nFilter, nOptions)
151
+ nFilter.limit = limit
152
+ nFilter.page = page
153
+ nFilter.skip = skip
154
+ nFilter.sort = sort
155
+ if (nOptions.queryHandler) {
156
+ const scope = nOptions.req ? this.app[nOptions.req.ns] : this.plugin
157
+ nFilter.query = await options.queryHandler.call(scope, nFilter.query, nOptions.req)
158
+ }
159
+ return { filter: nFilter, options: nOptions }
160
+ }
161
+
162
+ /**
163
+ * Handles a request for a given action trigger.
164
+ * @async
165
+ * @memberof module:Helper/Model
166
+ * @method
167
+ * @name handleReq
168
+ * @param {string|number} id - The ID of the record.
169
+ * @param {string} trigger - The action trigger (e.g., 'created', 'updated', 'removed').
170
+ * @param {DoboModel.TOptions} [options={}] - Additional options for handling the request.
171
+ * @returns {Promise<void>}
172
+ */
173
+ export async function handleReq (id, trigger, options = {}) {
174
+ const { upperFirst } = this.app.lib._
175
+ if (options.req) {
176
+ if (options.req.file && trigger !== 'removed') await handleAttachmentUpload.call(this, id, trigger, options)
177
+ if (options.req.flash && !options.noFlash) options.req.flash('notify', options.req.t(`record${upperFirst(trigger)}`))
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Merges attachment information into the given record.
183
+ * @async
184
+ * @memberof module:Helper/Model
185
+ * @method
186
+ * @name mergeAttachmentInfo
187
+ * @param {Object} rec - The record to merge attachment info into.
188
+ * @param {string} source - The source file path of the attachment.
189
+ * @param {Object} options - Additional options including mimeType, stats, and fullPath.
190
+ * @returns {Promise<void>}
191
+ */
192
+ export async function mergeAttachmentInfo (rec, source, options = {}) {
193
+ if (!this.app.waibu) return
194
+ const { mimeType, stats, fullPath } = options
195
+ const { importPkg } = this.app.bajo
196
+ const { fs } = this.app.lib
197
+ const { pick } = this.app.lib._
198
+ const mime = await importPkg('waibu:mime')
199
+
200
+ if (mimeType) rec.mimeType = mime.getType(rec.file)
201
+ if (fullPath) rec.fullPath = source
202
+ if (stats) {
203
+ const s = fs.statSync(source)
204
+ rec.stats = pick(s, ['size', 'atime', 'ctime', 'mtime'])
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Gets the attachment path for a given record and field.
210
+ * @async
211
+ * @memberof module:Helper/Model
212
+ * @method
213
+ * @name getAttachmentPath
214
+ * @param {string|number} id - The ID of the record.
215
+ * @param {string} field - The field name of the attachment.
216
+ * @param {string} file - The file name of the attachment.
217
+ * @param {Object} options - Additional options, including dirOnly.
218
+ * @returns {Promise<string>} The path to the attachment.
219
+ */
220
+ export async function getAttachmentPath (id, field, file, options = {}) {
221
+ const { fs } = this.app.lib
222
+ const dir = `${this.app.getPluginDataDir(this.app.dobo.ns)}/attachment/${this.name}/${id}`
223
+ if (options.dirOnly) return dir
224
+ const path = field ? `${dir}/${field}/${file}` : `${dir}/${file}`
225
+ if (!fs.existsSync(path)) throw this.app.dobo.error('notFound')
226
+ return path
227
+ }
228
+
229
+ /**
230
+ * Copies attachments for a given record.
231
+ * @name copyAttachment
232
+ * @async
233
+ * @memberof module:Helper/Model
234
+ * @method
235
+ * @param {string|number} id - The ID of the record.
236
+ * @param {Object} options - Additional options for copying attachments.
237
+ * @returns {Promise<Array>} The copied attachment records.
238
+ */
239
+ export async function copyAttachment (id, options = {}) {
240
+ if (!this.app.waibu) return
241
+ if (!this.options.attachment) return
242
+ const { fs } = this.app.lib
243
+ const { req, setField, setFile, mimeType, stats } = options
244
+ const { dir, files } = await this.app.waibu.getUploadedFiles(req.id, false, true)
245
+ const result = []
246
+ if (files.length === 0) return result
247
+ for (const f of files) {
248
+ let [field, ...parts] = path.basename(f).split('@')
249
+ if (parts.length === 0) continue
250
+ field = setField ?? field
251
+ const file = setFile ?? parts.join('@')
252
+ const opts = { source: f, field, file, mimeType, stats, req }
253
+ const rec = await this.createAttachment(id, opts)
254
+ if (!rec) continue
255
+ delete rec.dir
256
+ result.push(rec)
257
+ if (setField || setFile) break
258
+ }
259
+ fs.removeSync(dir)
260
+ return result
261
+ }
262
+
263
+ /**
264
+ * Handles attachment uploads for a given record and trigger.
265
+ * @async
266
+ * @memberof module:Helper/Model
267
+ * @method
268
+ * @name handleAttachmentUpload
269
+ * @param {string|number} id - The ID of the record.
270
+ * @param {string} trigger - The action trigger (e.g., 'added', 'removed').
271
+ * @param {Object} options - Additional options for handling the upload.
272
+ * @returns {Promise<void>}
273
+ */
274
+ export async function handleAttachmentUpload (id, trigger, options = {}) {
275
+ if (!this.options.attachment) return
276
+ const { fs } = this.app.lib
277
+ const { req, mimeType, stats, setFile, setField } = options
278
+ if (trigger === 'removed') {
279
+ const dir = `${this.app.getPluginDataDir(this.app.dobo.ns)}/attachment/${this.name}/${id}`
280
+ await fs.remove(dir)
281
+ return
282
+ }
283
+ return copyAttachment.call(this, id, { req, mimeType, stats, setFile, setField })
284
+ }
285
+
286
+ /**
287
+ * Gets reference records for the given records.
288
+ * @async
289
+ * @memberof module:Helper/Model
290
+ * @method
291
+ * @name getRefs
292
+ * @param {Array<Object>} records - The records to get references for.
293
+ * @param {Object} options - Additional options for fetching references.
294
+ * @returns {Promise<void>}
295
+ */
296
+ export async function getRefs (records = [], options = {}) {
297
+ const { isSet } = this.app.lib.aneka
298
+ const { uniq, without } = this.app.lib._
299
+ const { parseQuery } = this.app.dobo
300
+ const props = this.getNonVirtualProperties().filter(p => isSet(p.ref) && !(options.hidden ?? []).includes(p.name))
301
+ options.refs = options.refs ?? []
302
+ if (props.length > 0) {
303
+ for (const prop of props) {
304
+ for (const key in prop.ref) {
305
+ try {
306
+ if (records.length === 0) return
307
+ const isValues = Array.isArray(records[0][prop.name])
308
+ const ref = prop.ref[key]
309
+ const rModel = this.app.dobo.getModel(ref.model, true)
310
+ if (!rModel) return
311
+ let matches = []
312
+ for (const rec of records) {
313
+ const items = isValues ? [...(rec[prop.name] ?? [])] : (rec[prop.name] ? [rec[prop.name]] : [])
314
+ matches.push(...items.map(item => prop.name === 'id' ? rModel.sanitizeId(item) : item))
315
+ }
316
+ matches = uniq(without(matches, undefined, null, NaN)).map(i => i + '')
317
+ let query = {}
318
+ query[ref.field] = { $in: matches }
319
+
320
+ const siteIdProp = this.properties.find(item => item.name === 'siteId')
321
+ const siteIdRProp = rModel.properties.find(item => item.name === 'siteId')
322
+ if (siteIdProp && siteIdRProp) query.siteId = records[0].siteId
323
+
324
+ if (ref.query) query = { $and: [query, parseQuery(ref.query, rModel)] }
325
+ const filter = { query, limit: matches.length }
326
+ if (!((typeof options.refs === 'string' && ['*', 'all'].includes(options.refs)) || options.refs.includes(key))) return
327
+ if (ref.fields.length === 0) return
328
+ const { fmt, req } = options
329
+ const fields = [...ref.fields]
330
+ if (!fields.includes(prop.name)) fields.push(prop.name)
331
+ const rOptions = { dataOnly: true, refs: [], fmt, req, fields }
332
+ const results = await rModel.findRecord(filter, rOptions)
333
+ for (const i in records) {
334
+ records[i]._ref = records[i]._ref ?? {}
335
+ const rec = records[i]
336
+ let items = isValues ? [...(rec[prop.name] ?? [])] : (rec[prop.name] ? [rec[prop.name]] : [])
337
+ items = items.map(item => item + '')
338
+ const res = results.filter(r => items.includes(r[ref.field] + ''))
339
+ if (res.length === 0) records[i]._ref[key] = isValues ? [] : {}
340
+ else records[i]._ref[key] = isValues ? res : res[0]
341
+ }
342
+ } catch (err) {
343
+ if (this.app.bajo.config.log.level === 'trace') console.error(err)
344
+ }
345
+ }
346
+ }
347
+ }
348
+ }
349
+
350
+ /**
351
+ * Builds a sanitized filter query for the given filter.
352
+ * @memberof module:Helper/Model
353
+ * @method
354
+ * @name buildFilterQuery
355
+ * @param {Object} filter - The filter object containing query parameters.
356
+ * @returns {Object} The sanitized query object.
357
+ */
358
+ export function buildFilterQuery (filter = {}) {
359
+ const { parseQuery } = this.app.dobo
360
+ const query = parseQuery(filter.query ?? {}, this, false)
361
+ return sanitizeQuery.call(this, query)
362
+ }
363
+
364
+ /**
365
+ * Sanitizes a query object by ensuring that its fields and values conform to the model's schema.
366
+ * @memberof module:Helper/Model
367
+ * @method
368
+ * @name sanitizeQuery
369
+ * @param {Object} query - The query object to sanitize.
370
+ * @param {string} parent - The parent field name, if applicable.
371
+ * @returns {Object} The sanitized query object.
372
+ */
373
+ export function sanitizeQuery (query = {}, parent) {
374
+ const { isPlainObject, isArray, find, cloneDeep } = this.app.lib._
375
+ const { isSet } = this.app.lib.aneka
376
+ const { dayjs } = this.app.lib
377
+ const obj = cloneDeep(query)
378
+ const keys = Object.keys(obj)
379
+
380
+ const sanitizeField = (prop, val) => {
381
+ if (!prop) return val
382
+ if (val instanceof RegExp) return val
383
+ if (['datetime'].includes(prop.type)) {
384
+ const dt = dayjs(val)
385
+ return dt.isValid() ? dt.toDate() : val
386
+ } else if (['smallint', 'integer'].includes(prop.type)) return parseInt(val) || val
387
+ else if (['float', 'double'].includes(prop.type)) return parseFloat(val) || val
388
+ else if (['boolean'].includes(prop.type)) return !!val
389
+ else if (['string', 'text'].includes(prop.type)) return val + ''
390
+ return val
391
+ }
392
+
393
+ const sanitizeChild = (key, val, p) => {
394
+ if (!isSet(val)) return val
395
+ const prop = find(this.properties, { name: key.startsWith('$') ? p : key })
396
+ if (!prop) return val
397
+ return sanitizeField(prop, val)
398
+ }
399
+
400
+ keys.forEach(k => {
401
+ const v = obj[k]
402
+ const props = this.getProperties({ noVirtual: true })
403
+ const fields = props.map(p => p.name)
404
+ const prop = find(props, { name: k })
405
+ if (k[0] !== '$' && !fields.includes(k)) {
406
+ delete keys[k]
407
+ } else {
408
+ if (isPlainObject(v)) obj[k] = sanitizeQuery.call(this, v, k)
409
+ else if (isArray(v)) {
410
+ v.forEach((i, idx) => {
411
+ if (isPlainObject(i)) obj[k][idx] = sanitizeQuery.call(this, i, k)
412
+ else obj[k][idx] = sanitizeField(prop, i)
413
+ })
414
+ } else obj[k] = sanitizeChild(k, v, parent)
415
+ }
416
+ })
417
+ return obj
418
+ }
419
+
420
+ /**
421
+ * Builds a search filter from the given filter object.
422
+ * @memberof module:Helper/Model
423
+ * @method
424
+ * @name buildFilterSearch
425
+ * @param {Object} filter - The filter object containing search parameters.
426
+ * @returns {Object} The constructed search filter.
427
+ */
428
+ export function buildFilterSearch (filter = {}) {
429
+ const { isPlainObject, trim, has, uniq } = this.app.lib._
430
+ const search = filter.search ?? {}
431
+ let input = search
432
+ if (isPlainObject(input)) return input
433
+ const split = (value) => {
434
+ let [field, val] = value.split(':').map(i => i.trim())
435
+ if (!val) {
436
+ val = field
437
+ field = '*'
438
+ }
439
+ return { field, value: val }
440
+ }
441
+ input = trim(input)
442
+ let items = {}
443
+ if (isPlainObject(input)) items = input
444
+ else if (input[0] === '{') {
445
+ try {
446
+ items = JSON.parse(input)
447
+ } catch (err) {}
448
+ } else {
449
+ for (const item of input.split('+').map(i => i.trim())) {
450
+ const part = split(item, ' ')
451
+ if (!items[part.field]) items[part.field] = []
452
+ items[part.field].push(...part.value.split(' ').filter(v => ![''].includes(v)))
453
+ }
454
+ }
455
+ let s = {}
456
+ for (const index of this.indexes.filter(i => i.type === 'fulltext')) {
457
+ for (const f of index.fields) {
458
+ const value = []
459
+ if (typeof items[f] === 'string') items[f] = [items[f]]
460
+ if (has(items, f)) value.push(...items[f])
461
+ if (!s[f]) s[f] = []
462
+ s[f] = uniq([...s[f], ...value])
463
+ }
464
+ }
465
+ if (has(items, '*')) s['*'] = items['*']
466
+ if (this.adapter.idField.name !== 'id') {
467
+ const search = JSON.stringify(s).replaceAll('"id"', `"${this.adapter.idField.name}"`)
468
+ try {
469
+ s = JSON.parse(search)
470
+ } catch (err) {}
471
+ }
472
+ return s
473
+ }
474
+
475
+ /**
476
+ * Prepare pagination parameters for a query:
477
+ * - Ensures that the limit does not exceed the maximum allowed limit.
478
+ * - Ensures that the page number is within the allowed range.
479
+ * - Calculates the number of records to skip based on the page and limit.
480
+ * - Builds the sort order based on the provided sort input.
481
+ *
482
+ * @async
483
+ * @memberof module:Helper/Model
484
+ * @method
485
+ * @name preparePagination
486
+ * @param {DoboModel.TFilter} filter - The filter object containing pagination parameters.
487
+ * @param {DoboModel.TOptions} options - Additional options for pagination.
488
+ * @returns {Object} The prepared pagination parameters including limit, page, skip, and sort.
489
+ */
490
+ export function preparePagination (filter = {}, options = {}) {
491
+ const { isEmpty, map, each, isPlainObject, isString, trim, keys } = this.app.lib._
492
+ const { getDefaultValues, config } = this.app.dobo
493
+ const { limit: defLimit, maxLimit: defMaxLimit, maxPage: defMaxPage } = getDefaultValues(options)
494
+
495
+ const buildPageSkipLimit = (filter) => {
496
+ let limit = parseInt(filter.limit) || defLimit
497
+ if (limit === -1) limit = defMaxLimit
498
+ if (limit > defMaxLimit) {
499
+ options.warnings = options.warnings ?? []
500
+ options.warnings.push(options.req ? options.req.t('maxLimitWarning%s%s', limit, defMaxLimit) : this.plugin.t('maxLimitWarning%s', limit, defMaxLimit))
501
+ limit = defMaxLimit // TODO: notify as warning in response object
502
+ }
503
+ if (limit < 1) limit = 1
504
+ let page = parseInt(filter.page) || 1
505
+ if (page < 1) page = 1
506
+ if (page > defMaxPage) throw this.plugin.error('maxPageError%s%s', page, defMaxPage)
507
+ let skip = (page - 1) * limit
508
+ if (filter.skip) {
509
+ skip = parseInt(filter.skip) || skip
510
+ page = undefined
511
+ }
512
+ if (skip < 0) skip = 0
513
+ return { page, skip, limit }
514
+ }
515
+
516
+ const buildSort = (input, allowSortUnindexed) => {
517
+ let sort
518
+ if (isEmpty(input)) {
519
+ const columns = map(this.properties ?? [], 'name')
520
+ each(config.default.filter.sort, s => {
521
+ const [col] = s.split(':')
522
+ if (columns.includes(col)) {
523
+ input = s
524
+ return false
525
+ }
526
+ })
527
+ }
528
+ if (!isEmpty(input)) {
529
+ if (isPlainObject(input)) sort = input
530
+ else if (isString(input)) {
531
+ const item = {}
532
+ each(input.split('+'), text => {
533
+ let [col, dir] = map(trim(text).split(':'), i => trim(i))
534
+ dir = (dir ?? '').toUpperCase()
535
+ dir = dir === 'DESC' ? -1 : parseInt(dir) || 1
536
+ item[col] = dir / Math.abs(dir)
537
+ })
538
+ sort = item
539
+ }
540
+ const items = keys(sort)
541
+ each(items, i => {
542
+ if (!this.sortables.includes(i) && !allowSortUnindexed) throw this.app.dobo.error('sortOnUnindexedField%s%s', i, this.name)
543
+ // if (model.fullText.fields.includes(i)) throw this.error('Can\'t sort on full-text index: \'%s@%s\'', i, model.name)
544
+ })
545
+ }
546
+ return sort
547
+ }
548
+
549
+ const { page, skip, limit } = buildPageSkipLimit(filter)
550
+ let sortInput = filter.sort
551
+ try {
552
+ sortInput = JSON.parse(sortInput)
553
+ } catch (err) {
554
+ }
555
+ const sort = buildSort(sortInput, options.allowSortUnindexed)
556
+ return { limit, page, skip, sort }
557
+ }
558
+
559
+ /**
560
+ * Clears the cache for a specific record ID and related find operations.
561
+ * @async
562
+ * @memberof module:Helper/Model
563
+ * @method
564
+ * @name clearCache
565
+ * @param {string|number} id - The ID of the record for which to clear the cache.
566
+ * @returns {Promise<void>}
567
+ */
568
+ export async function clearCache (id) {
569
+ const { clear } = this.app.bajoCache ?? {}
570
+ if (!clear) return
571
+ await clear({ key: `dobo|${this.name}|getRecord|${id}` })
572
+ await clear({ key: `dobo|${this.name}|findRecord` })
573
+ await clear({ key: `dobo|${this.name}|findAllRecord` })
574
+ await clear({ key: `dobo|${this.name}|findOneRecord` })
575
+ }