dobo 2.14.1 → 2.16.0

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.
@@ -148,6 +148,7 @@
148
148
  "maxLimitWarning%s%s": "Records per page (%s rows) above the allowed threshold (%s rows)",
149
149
  "hardCapWarning%s%s": "Max records returned (%s rows) above the allowed threshold (%s rows)",
150
150
  "maxPageError%s%s": "Page number (%s) above the allowed threshold (%s)",
151
+ "duplicateRefKeys%s%s": "Duplicate reference keys found in '%s' (%s)",
151
152
  "field": {
152
153
  "id": "ID",
153
154
  "code": "Kode",
@@ -146,6 +146,7 @@
146
146
  "maxLimitWarning%s%s": "Data per halaman (%s baris) melampaui batas yang diijinkan (%s baris)",
147
147
  "hardCapWarning%s%s": "Maksimum data yang dihasilkan (%s baris) melampaui batas yang diijinkan (%s baris)",
148
148
  "maxPageError%s%s": "Nomor halaman (%s) melampaui batas yang diijinkan (%s)",
149
+ "duplicateRefKeys%s%s": "Ditemukan kunci referensi duplikat di '%s' (%s)",
149
150
  "field": {
150
151
  "id": "ID",
151
152
  "code": "Kode",
@@ -1,9 +1,9 @@
1
1
  async function createdAt (opts = {}) {
2
- opts.fieldName = opts.fieldName ?? 'createdAt'
2
+ opts.field = opts.field ?? 'createdAt'
3
3
  opts.noOverwrite = opts.noOverwrite ?? false
4
4
  return {
5
5
  properties: [{
6
- name: opts.fieldName,
6
+ name: opts.field,
7
7
  type: 'datetime',
8
8
  index: true
9
9
  }],
@@ -11,8 +11,8 @@ async function createdAt (opts = {}) {
11
11
  name: 'beforeCreateRecord',
12
12
  handler: async function (body, options) {
13
13
  const { isSet } = this.app.lib.aneka
14
- if (opts.noOverwrite) body[opts.fieldName] = new Date()
15
- else if (!isSet(body[opts.fieldName])) body[opts.fieldName] = new Date()
14
+ if (opts.noOverwrite) body[opts.field] = new Date()
15
+ else if (!isSet(body[opts.field])) body[opts.field] = new Date()
16
16
  }
17
17
  }]
18
18
  }
@@ -1,8 +1,8 @@
1
1
  async function dt (opts = {}) {
2
- opts.fieldName = opts.fieldName ?? 'dt'
2
+ opts.field = opts.field ?? 'dt'
3
3
  return {
4
4
  properties: [{
5
- name: opts.fieldName ?? 'dt',
5
+ name: opts.field ?? 'dt',
6
6
  type: 'datetime',
7
7
  required: opts.required ?? true,
8
8
  index: opts.index ?? true
@@ -1,27 +1,28 @@
1
- async function beforeRemoveRecord (id, opts) {
1
+ async function beforeRemoveRecord (id, opts, options) {
2
2
  const { get } = this.app.lib._
3
+ if (get(options, 'req.user.interSiteAdmin')) return
3
4
  const record = await this.driver.getRecord(this, id)
4
- const immutable = get(record.data, opts.fieldName)
5
+ const immutable = get(record.data, opts.field)
5
6
  if (immutable) throw this.plugin.error('recordImmutable%s%s', id, this.name, { statusCode: 423 })
6
7
  }
7
8
 
8
9
  async function immutable (opts = {}) {
9
- opts.fieldName = opts.fieldName ?? '_immutable'
10
+ opts.field = opts.field ?? '_immutable'
10
11
  return {
11
12
  properties: {
12
- name: opts.fieldName,
13
+ name: opts.field,
13
14
  type: 'boolean',
14
15
  hidden: true
15
16
  },
16
17
  hooks: [{
17
18
  name: 'beforeUpdateRecord',
18
19
  handler: async function (id, body, options) {
19
- await beforeRemoveRecord.call(this, id, opts)
20
+ await beforeRemoveRecord.call(this, id, opts, options)
20
21
  }
21
22
  }, {
22
23
  name: 'beforeRemoveRecord',
23
24
  handler: async function (id, options) {
24
- await beforeRemoveRecord.call(this, id, opts)
25
+ await beforeRemoveRecord.call(this, id, opts, options)
25
26
  }
26
27
  }]
27
28
  }
@@ -6,24 +6,24 @@ async function beforeFindRecord ({ filter = {} }, opts) {
6
6
  if (filter.query.$and) q.$and.push(...filter.query.$and)
7
7
  else q.$and.push(filter.query)
8
8
  }
9
- q.$and.push(set({}, opts.fieldName, null))
9
+ q.$and.push(set({}, opts.field, null))
10
10
  filter.query = q
11
11
  }
12
12
 
13
13
  async function afterGetRecord ({ id, record = {} }, opts) {
14
14
  const { isEmpty } = this.app.lib._
15
- if (!isEmpty(record.data[opts.fieldName])) throw this.error('recordNotFound%s%s', id, this.name, { statusCode: 404 })
15
+ if (!isEmpty(record.data[opts.field])) throw this.error('recordNotFound%s%s', id, this.name, { statusCode: 404 })
16
16
  }
17
17
 
18
18
  async function beforeCreateRecord ({ body = {} }, opts) {
19
- delete body[opts.fieldName]
19
+ delete body[opts.field]
20
20
  }
21
21
 
22
22
  async function removedAt (opts = {}) {
23
- opts.fieldName = opts.fieldName ?? '_removedAt'
23
+ opts.field = opts.field ?? '_removedAt'
24
24
  return {
25
25
  properties: {
26
- name: opts.fieldName,
26
+ name: opts.field,
27
27
  type: 'datetime',
28
28
  index: true,
29
29
  hidden: true
@@ -52,7 +52,7 @@ async function removedAt (opts = {}) {
52
52
  name: 'beforeRemoveRecord',
53
53
  handler: async function (id, options) {
54
54
  const { set } = this.app.lib._
55
- const body = set({}, opts.fieldName, new Date())
55
+ const body = set({}, opts.field, new Date())
56
56
  const record = await this.driver.recordUpdate(this, id, body, { noResult: false })
57
57
  options.record = { oldData: record.oldData }
58
58
  }
@@ -2,11 +2,11 @@ import crypto from 'crypto'
2
2
 
3
3
  async function unique (opts = {}) {
4
4
  const { omit } = this.app.lib._
5
- opts.fieldName = opts.fieldName ?? 'id'
5
+ opts.field = opts.field ?? 'id'
6
6
  opts.fields = opts.fields ?? []
7
7
  return {
8
8
  properties: [{
9
- name: opts.fieldName,
9
+ name: opts.field,
10
10
  type: 'string',
11
11
  maxLength: 32,
12
12
  required: true,
@@ -16,12 +16,12 @@ async function unique (opts = {}) {
16
16
  name: 'beforeCreateRecord',
17
17
  level: 1000,
18
18
  handler: async function (body, options) {
19
- if (opts.fields.length === 0) opts.fields = omit(this.properties.map(prop => prop.name), [opts.fieldName])
19
+ if (opts.fields.length === 0) opts.fields = omit(this.properties.map(prop => prop.name), [opts.field])
20
20
  const item = {}
21
21
  for (const f of opts.fields) {
22
22
  item[f] = body[f]
23
23
  }
24
- body[opts.fieldName] = crypto.createHash('md5').update(JSON.stringify(item)).digest('hex')
24
+ body[opts.field] = crypto.createHash('md5').update(JSON.stringify(item)).digest('hex')
25
25
  }
26
26
  }]
27
27
  }
@@ -1,24 +1,24 @@
1
1
  async function updatedAt (opts = {}) {
2
2
  const { isSet } = this.app.lib.aneka
3
- opts.fieldName = opts.fieldName ?? 'updatedAt'
3
+ opts.field = opts.field ?? 'updatedAt'
4
4
  opts.noOverwrite = opts.noOverwrite ?? false
5
5
  return {
6
6
  properties: {
7
- name: opts.fieldName,
7
+ name: opts.field,
8
8
  type: 'datetime',
9
9
  index: true
10
10
  },
11
11
  hooks: [{
12
12
  name: 'beforeCreateRecord',
13
13
  handler: async function (body, options) {
14
- if (opts.noOverwrite) body[opts.fieldName] = new Date()
15
- else if (!isSet(body[opts.fieldName])) body[opts.fieldName] = new Date()
14
+ if (opts.noOverwrite) body[opts.field] = new Date()
15
+ else if (!isSet(body[opts.field])) body[opts.field] = new Date()
16
16
  }
17
17
  }, {
18
18
  name: 'beforeUpdateRecord',
19
19
  handler: async function (id, body, options) {
20
- if (opts.noOverwrite) body[opts.fieldName] = new Date()
21
- else if (!isSet(body[opts.fieldName])) body[opts.fieldName] = new Date()
20
+ if (opts.noOverwrite) body[opts.field] = new Date()
21
+ else if (!isSet(body[opts.field])) body[opts.field] = new Date()
22
22
  }
23
23
  }]
24
24
  }
@@ -7,7 +7,7 @@ async function attachment (req, reply) {
7
7
  const { fs } = this.app.lib
8
8
  const mdl = this.app.dobo.getModel(req.params.model)
9
9
  const type = req.query.type
10
- const items = (await mdl.listAttachments({ id: req.params.id, fieldName: req.params.field, file: '*', type })) ?? []
10
+ const items = (await mdl.listAttachments({ id: req.params.id, field: req.params.field, file: '*', type })) ?? []
11
11
  let item
12
12
  if (req.params.file === '_first') item = items[0]
13
13
  else if (req.params.file === '_last') item = last(items)
package/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import nql from '@tryghost/nql'
1
2
  import collectConnections from './lib/collect-connections.js'
2
3
  import collectDrivers from './lib/collect-drivers.js'
3
4
  import collectFeatures from './lib/collect-features.js'
@@ -81,7 +82,7 @@ const propertyType = {
81
82
  }
82
83
  }
83
84
 
84
- const commonPropertyTypes = ['name', 'type', 'required', 'rules', 'validator', 'ref', 'default', 'values', 'rulesMsg', 'immutable', 'feature']
85
+ const commonPropertyTypes = ['name', 'type', 'required', 'rules', 'validator', 'ref', 'default', 'values', 'rulesMsg', 'immutable', 'feature', 'format']
85
86
 
86
87
  /**
87
88
  * Plugin factory
@@ -150,8 +151,8 @@ async function factory (pkgName) {
150
151
  filter: {
151
152
  limit: 25, // num of records per page
152
153
  maxLimit: 200, // max num of records per page
153
- maxPage: 50, // max allowed page number
154
- sort: ['dt:-1', 'updatedAt:-1', 'updated_at:-1', 'createdAt:-1', 'createdAt:-1', 'ts:-1', 'username', 'name']
154
+ maxPage: 400, // max allowed page number
155
+ sort: ['dt:-1', 'updatedAt:-1', 'createdAt:-1', 'ts:-1', 'username', 'name']
155
156
  },
156
157
  cache: {
157
158
  ttlDur: '10s'
@@ -496,13 +497,10 @@ async function factory (pkgName) {
496
497
  }
497
498
 
498
499
  getDefaultValues = (options = {}) => {
499
- const { get } = this.app.lib._
500
- const config = this.app.dobo.config
501
- const limit = get(options, 'req.site.setting.dobo.default.filter.limit', config.default.filter.limit)
502
- const maxLimit = get(options, 'req.site.setting.dobo.default.filter.maxLimit', config.default.filter.maxLimit)
503
- const maxPage = get(options, 'req.site.setting.dobo.default.filter.maxPage', config.default.filter.maxPage)
504
- const hardCap = get(options, 'req.site.setting.dobo.default.hardCap', config.default.hardCap)
505
- const warnings = get(options, 'req.site.setting.dobo.default.warnings', config.default.warnings)
500
+ const key = 'default.filter'
501
+ let config = this.app.dobo.getConfig(key)
502
+ if (options.req) config = options.req.getSetting(`dobo:${key}`, config)
503
+ const { limit, maxLimit, maxPage, hardCap, warnings } = config
506
504
  const t = options.req ? options.req.t : this.t
507
505
  return { limit, maxLimit, hardCap, maxPage, warnings, t }
508
506
  }
@@ -518,6 +516,63 @@ async function factory (pkgName) {
518
516
  }
519
517
  }
520
518
  }
519
+
520
+ parseNql = (text) => {
521
+ const sanitized = text.split('+').map(item => {
522
+ const [key, ...rest] = item.split(':').map(i => i.trim())
523
+ let value = rest.join(':')
524
+ const neg = value[1] === '-' ? '-' : ''
525
+ if ((value[0] === '{' || value[1] === '{') && value[value.length - 1] === '}') {
526
+ if (value[0] === '-') value = value.slice(1)
527
+ const items = value.slice(1, -1).split(',')
528
+ value = `${neg}>=${items[0]}+${key}:${neg}<=${items[1]}`
529
+ }
530
+ return `${key}:${value}`
531
+ }).join('+')
532
+ return nql(sanitized).parse()
533
+ }
534
+
535
+ parseQuery = (query, silent = true) => {
536
+ const { isPlainObject, trim } = this.app.lib._
537
+ let result = {}
538
+ if (isPlainObject(query)) result = query
539
+ else {
540
+ query = trim(query)
541
+ try {
542
+ if (query.startsWith('{')) result = JSON.parse(query)
543
+ else result = this.parseNql(query)
544
+ } catch (err) {
545
+ if (silent) return {}
546
+ throw err
547
+ }
548
+ }
549
+ try {
550
+ const text = JSON.stringify(result)
551
+ if (text.includes('["__REGEXP__",')) result = this.reviveRegexInJson(text)
552
+ } catch (err) {}
553
+ return result
554
+ }
555
+
556
+ replaceRegexInJson = (input = {}, returnString = true) => {
557
+ const { isString } = this.app.lib._
558
+ if (isString(input)) input = JSON.parse(input) ?? {}
559
+ const result = JSON.stringify(input, (key, value) => {
560
+ if (value instanceof RegExp) return ['__REGEXP__', value.source, value.flags]
561
+ return value
562
+ })
563
+ if (returnString) return result
564
+ return JSON.parse(result)
565
+ }
566
+
567
+ reviveRegexInJson = (input, returnObject = true) => {
568
+ const { isPlainObject } = this.app.lib._
569
+ if (isPlainObject(input)) input = JSON.stringify(input)
570
+ const result = JSON.parse(input, (key, value) => {
571
+ if (Array.isArray(value) && value[0] === '__REGEXP__') return { $regex: new RegExp(value[1], value[2]) }
572
+ return value
573
+ })
574
+ return returnObject ? result : JSON.stringify(result)
575
+ }
521
576
  }
522
577
 
523
578
  return Dobo
@@ -10,7 +10,7 @@ import actionFactory from './factory/action.js'
10
10
  * @param {Array} [indexes] - Container array to fill up found index
11
11
  */
12
12
  async function sanitizeProp (model, prop, indexes) {
13
- const { isEmpty, isString, keys, pick, isArray, isPlainObject, camelCase } = this.app.lib._
13
+ const { isEmpty, isString, keys, pick, isArray, isPlainObject } = this.app.lib._
14
14
  const allPropKeys = this.getAllPropertyKeys(model.connection.driver)
15
15
  const propType = this.constructor.propertyType
16
16
  if (isString(prop)) {
@@ -25,7 +25,7 @@ async function sanitizeProp (model, prop, indexes) {
25
25
  if (isArray(prop.values)) {
26
26
  prop.values = prop.values.map(item => {
27
27
  if (isPlainObject(item)) return pick(item, ['value', 'text'])
28
- return { value: item, text: camelCase(item) }
28
+ return { value: item, text: item }
29
29
  })
30
30
  } else if (!isString(prop.values)) delete prop.values
31
31
  if (prop.index) {
@@ -39,7 +39,7 @@ async function sanitizeProp (model, prop, indexes) {
39
39
  else {
40
40
  const feature = this.getFeature(prop.type)
41
41
  if (!feature) this.fatal('unknownPropType%s%s', prop.type, model.name)
42
- await applyFeature.call(this, model, feature, { name: prop.name }, indexes)
42
+ await applyFeature.call(this, model, feature, { field: prop.name }, indexes)
43
43
  }
44
44
  }
45
45
 
@@ -74,12 +74,13 @@ async function findAllProps (model, inputs = [], indexes = [], isExtender) {
74
74
  */
75
75
  async function applyFeature (model, feature, options, indexes, isExtender) {
76
76
  const { isArray, findIndex } = this.app.lib._
77
- if (feature.name === 'dobo:unique' && options.fieldName === 'id') {
77
+ if (feature.name === 'dobo:unique' && options.field === 'id') {
78
78
  const idx = findIndex(model.properties, { name: 'id' })
79
79
  if (idx > -1) model.properties.pullAt(idx)
80
80
  }
81
81
  const item = await feature.handler(options)
82
82
  if (item.rules) model.rules.push(...item.rules)
83
+ if (item.scanables) model.scanables.push(...item.scanables)
83
84
  if (!isArray(item.properties)) item.properties = [item.properties]
84
85
  for (const prop of item.properties) {
85
86
  prop.feature = `${feature.plugin.ns}:${feature.name}`
@@ -137,24 +138,29 @@ async function findAllIndexes (model, inputs = [], indexes = []) {
137
138
  export async function sanitizeRef (model, models) {
138
139
  const { find, isString, pullAt } = this.app.lib._
139
140
  if (!models) models = this.models
141
+ const _refKeys = []
140
142
  for (const prop of model.properties) {
141
143
  const ignored = []
142
144
  for (const key in prop.ref ?? {}) {
145
+ _refKeys.push(key)
143
146
  let ref = prop.ref[key]
144
147
  if (isString(ref)) {
145
- ref = { propName: ref }
148
+ ref = { field: ref }
146
149
  }
150
+ ref.field = ref.field ?? 'id'
147
151
  ref.type = ref.type ?? '1:1'
152
+ ref.searchField = ref.searchField ?? model.scanables[0] ?? ref.field
153
+ ref.labelField = ref.labelField ?? ref.searchField
148
154
  const rModel = find(models, { name: ref.model })
149
155
  if (!rModel) {
150
156
  ignored.push(key)
151
157
  this.log.warn('notFound%s%s', this.t('model'), ref.model)
152
158
  continue
153
159
  }
154
- const rProp = find(rModel.properties, { name: ref.propName })
160
+ const rProp = find(rModel.properties, { name: ref.field })
155
161
  if (!rProp) {
156
162
  ignored.push(key)
157
- this.log.warn('notFound%s%s', this.t('property'), `${ref.propName}@${ref.model}`)
163
+ this.log.warn('notFound%s%s', this.t('property'), `${ref.field}@${ref.model}`)
158
164
  continue
159
165
  }
160
166
  ref.fields = ref.fields ?? '*'
@@ -172,6 +178,8 @@ export async function sanitizeRef (model, models) {
172
178
  delete prop.ref[key]
173
179
  }
174
180
  }
181
+ const dupes = _refKeys.filter((item, index) => _refKeys.indexOf(item) !== index)
182
+ if (dupes.length > 0) throw this.error('duplicateRefKeys%s%s', model.name, dupes.join(', '))
175
183
  }
176
184
 
177
185
  /**
@@ -333,7 +341,7 @@ async function collectModels () {
333
341
  me.models.push(model)
334
342
  }
335
343
  for (const model of me.models) {
336
- await sanitizeRef.call(this, model, me.models, true)
344
+ await sanitizeRef.call(this, model, me.models)
337
345
  me.log.trace('- %s', model.name)
338
346
  }
339
347
  this.log.debug('collected%s%d', this.t('model'), this.models.length)
@@ -13,9 +13,9 @@ const methods = {
13
13
  createHistogram: ['params'],
14
14
  createAttachment: ['id'],
15
15
  findAttachment: ['id'],
16
- getAttachment: ['id', 'fieldName', 'file'],
16
+ getAttachment: ['id', 'field', 'file'],
17
17
  listAttachment: ['params'],
18
- removeAttachment: ['id', 'fieldName', 'file'],
18
+ removeAttachment: ['id', 'field', 'file'],
19
19
  updateAttachment: ['id']
20
20
  }
21
21
 
@@ -123,8 +123,8 @@ async function actionFactory () {
123
123
  return this
124
124
  }
125
125
 
126
- fieldName = value => {
127
- this._fieldName = value
126
+ field = value => {
127
+ this._field = value
128
128
  return this
129
129
  }
130
130
 
@@ -147,8 +147,8 @@ async function actionFactory () {
147
147
  return await this.model[this.name](...args)
148
148
  }
149
149
 
150
- dispose () {
151
- super.dispose()
150
+ dispose = async () => {
151
+ await super.dispose()
152
152
  this.model = null
153
153
  this._options = null
154
154
  }
@@ -50,8 +50,8 @@ async function connectionFactory () {
50
50
  if (client) this.client = client
51
51
  }
52
52
 
53
- dispose () {
54
- super.dispose()
53
+ dispose = async () => {
54
+ await super.dispose()
55
55
  this.driver = null
56
56
  }
57
57
  }
@@ -19,8 +19,8 @@ async function featureFactory () {
19
19
  this.handler = options.handler
20
20
  }
21
21
 
22
- dispose () {
23
- super.dispose()
22
+ dispose = async () => {
23
+ await super.dispose()
24
24
  this.name = null
25
25
  this.handler = null
26
26
  }
@@ -1,5 +1,4 @@
1
1
  import path from 'path'
2
- import nql from '@tryghost/nql'
3
2
 
4
3
  export const omittedOptionsKeys = ['req', 'reply', 'trx']
5
4
 
@@ -115,12 +114,12 @@ export async function mergeAttachmentInfo (rec, source, options = {}) {
115
114
  }
116
115
  }
117
116
 
118
- export async function getAttachmentPath (id, fieldName, file, options = {}) {
117
+ export async function getAttachmentPath (id, field, file, options = {}) {
119
118
  const { getPluginDataDir } = this.app.bajo
120
119
  const { fs } = this.app.lib
121
120
  const dir = `${getPluginDataDir(this.app.dobo.ns)}/attachment/${this.name}/${id}`
122
121
  if (options.dirOnly) return dir
123
- const path = fieldName ? `${dir}/${fieldName}/${file}` : `${dir}/${file}`
122
+ const path = field ? `${dir}/${field}/${file}` : `${dir}/${file}`
124
123
  if (!fs.existsSync(path)) throw this.app.dobo.error('notFound')
125
124
  return path
126
125
  }
@@ -134,11 +133,11 @@ export async function copyAttachment (id, options = {}) {
134
133
  const result = []
135
134
  if (files.length === 0) return result
136
135
  for (const f of files) {
137
- let [fieldName, ...parts] = path.basename(f).split('@')
136
+ let [field, ...parts] = path.basename(f).split('@')
138
137
  if (parts.length === 0) continue
139
- fieldName = setField ?? fieldName
138
+ field = setField ?? field
140
139
  const file = setFile ?? parts.join('@')
141
- const opts = { source: f, fieldName, file, mimeType, stats, req }
140
+ const opts = { source: f, field, file, mimeType, stats, req }
142
141
  const rec = await this.createAttachment(id, opts)
143
142
  if (!rec) continue
144
143
  delete rec.dir
@@ -162,8 +161,20 @@ export async function handleAttachmentUpload (id, trigger, options = {}) {
162
161
  return copyAttachment.call(this, id, { req, mimeType, stats, setFile, setField })
163
162
  }
164
163
 
164
+ async function _getRef ({ ref, rModel, prop, key, options, filter } = {}) {
165
+ if (!((typeof options.refs === 'string' && ['*', 'all'].includes(options.refs)) || options.refs.includes(key))) return
166
+ if (ref.fields.length === 0) return
167
+ const { formatValue, retainOriginalValue } = options
168
+ const fields = [...ref.fields]
169
+ if (!fields.includes(prop.name)) fields.push(prop.name)
170
+ const rOptions = { dataOnly: true, refs: [], formatValue, retainOriginalValue, fields }
171
+ const results = await rModel.findRecord(filter, rOptions)
172
+ return { rOptions, results }
173
+ }
174
+
165
175
  export async function getSingleRef (record = {}, options = {}) {
166
176
  const { isSet } = this.app.lib.aneka
177
+ const { parseQuery } = this.app.dobo
167
178
  const { get } = this.app.lib._
168
179
  const props = this.properties.filter(p => isSet(p.ref) && !(options.hidden ?? []).includes(p.name))
169
180
  const refs = {}
@@ -172,21 +183,21 @@ export async function getSingleRef (record = {}, options = {}) {
172
183
  for (const prop of props) {
173
184
  for (const key in prop.ref) {
174
185
  try {
175
- if (!((typeof options.refs === 'string' && ['*', 'all'].includes(options.refs)) || options.refs.includes(key))) continue
176
- const ref = prop.ref[key]
177
- if (ref.fields.length === 0) continue
178
186
  if (get(record, `_ref.${key}`)) continue
179
- const rModel = this.app.dobo.getModel(ref.model)
180
- const query = {}
181
- query[ref.propName] = record[prop.name]
182
- if (ref.propName === 'id') query[ref.propName] = this.sanitizeId(query[ref.propName])
183
- const rFilter = { query }
184
- const rOptions = { dataOnly: true, refs: [] }
185
- const results = await rModel.findRecord(rFilter, rOptions)
186
- const fields = [...ref.fields]
187
+ const ref = prop.ref[key]
188
+ const rModel = this.app.dobo.getModel(ref.model, true)
189
+ if (!rModel) return
190
+ let query = {}
191
+ query[ref.field] = record[prop.name]
192
+ if (ref.field === 'id') query[ref.field] = this.sanitizeId(query[ref.field])
193
+ if (ref.query) query = { $and: [query, parseQuery(ref.query)] }
194
+ const filter = { query }
195
+ const resp = await _getRef.call(this, { ref, rModel, prop, key, options, filter })
196
+ if (!resp) continue
197
+ const { rOptions, results } = resp
187
198
  const data = []
188
199
  for (const res of results) {
189
- data.push(await rModel.sanitizeRecord(res, { fields }))
200
+ data.push(await rModel.sanitizeRecord(res, rOptions))
190
201
  }
191
202
  refs[key] = ['1:1'].includes(ref.type) ? data[0] : data
192
203
  } catch (err) {}
@@ -199,35 +210,34 @@ export async function getSingleRef (record = {}, options = {}) {
199
210
  export async function getMultiRefs (records = [], options = {}) {
200
211
  const { isSet } = this.app.lib.aneka
201
212
  const { uniq, without, get } = this.app.lib._
213
+ const { parseQuery } = this.app.dobo
202
214
  const props = this.properties.filter(p => isSet(p.ref) && !(options.hidden ?? []).includes(p.name))
203
215
  options.refs = options.refs ?? []
204
216
  if (props.length > 0) {
205
217
  for (const prop of props) {
206
218
  for (const key in prop.ref) {
207
219
  try {
208
- if (!((typeof options.refs === 'string' && ['*', 'all'].includes(options.refs)) || options.refs.includes(key))) continue
209
- const ref = prop.ref[key]
210
- if (ref.fields.length === 0) continue
211
- if (ref.type !== '1:1') continue
212
220
  if (get(records, `0._ref.${key}`)) continue
213
- const rModel = this.app.dobo.getModel(ref.model)
221
+ const ref = prop.ref[key]
222
+ const rModel = this.app.dobo.getModel(ref.model, true)
223
+ if (!rModel) return
214
224
  let matches = []
215
225
  for (const r of records) {
216
226
  matches.push(rModel.sanitizeId(r[prop.name]))
217
227
  }
218
228
  matches = uniq(without(matches, undefined, null, NaN))
219
- const query = {}
220
- query[ref.propName] = { $in: matches }
221
- const rFilter = { query, limit: matches.length }
222
- const rOptions = { dataOnly: true, refs: [] }
223
- const results = await rModel.findRecord(rFilter, rOptions)
224
- const fields = [...ref.fields]
225
- if (!fields.includes(prop.name)) fields.push(prop.name)
229
+ let query = {}
230
+ query[ref.field] = { $in: matches }
231
+ if (ref.query) query = { $and: [query, parseQuery(ref.query)] }
232
+ const filter = { query, limit: matches.length }
233
+ const resp = await _getRef.call(this, { ref, rModel, prop, key, options, filter })
234
+ if (!resp) continue
235
+ const { rOptions, results } = resp
226
236
  for (const i in records) {
227
237
  records[i]._ref = records[i]._ref ?? {}
228
238
  const rec = records[i]
229
- const res = results.find(res => (res[ref.propName] + '') === rec[prop.name] + '')
230
- if (res) records[i]._ref[key] = await rModel.sanitizeRecord(res, { fields })
239
+ const res = results.find(res => (res[ref.field] + '') === rec[prop.name] + '')
240
+ if (res) records[i]._ref[key] = await rModel.sanitizeRecord(res, rOptions)
231
241
  else records[i]._ref[key] = {}
232
242
  }
233
243
  } catch (err) {}
@@ -236,31 +246,16 @@ export async function getMultiRefs (records = [], options = {}) {
236
246
  }
237
247
  }
238
248
 
239
- export function parseNql (text) {
240
- const sanitized = text.split('+').map(item => {
241
- const [key, ...rest] = item.split(':').map(i => i.trim())
242
- let value = rest.join(':')
243
- const neg = value[1] === '-' ? '-' : ''
244
- if ((value[0] === '{' || value[1] === '{') && value[value.length - 1] === '}') {
245
- if (value[0] === '-') value = value.slice(1)
246
- const items = value.slice(1, -1).split(',')
247
- value = `${neg}>=${items[0]}+${key}:${neg}<=${items[1]}`
248
- }
249
- return `${key}:${value}`
250
- }).join('+')
251
-
252
- return nql(sanitized).parse()
253
- }
254
-
255
249
  export function buildFilterQuery (filter = {}) {
256
250
  const { trim, find, isString, isPlainObject } = this.app.lib._
251
+ const { parseNql } = this.app.dobo
257
252
  let query = filter.query ?? {}
258
253
  let q = {}
259
254
  if (isString(query)) {
260
255
  try {
261
256
  query = trim(query)
262
257
  if (query.startsWith('{')) q = JSON.parse(query) // JSON formatted query
263
- else if (query.includes(':')) q = parseNql.call(this, query) // NQL
258
+ else if (query.includes(':')) q = parseNql(query) // NQL
264
259
  else {
265
260
  let scanables = [...this.scanables]
266
261
  if (scanables.length === 0) scanables = [...this.sortables]
@@ -273,8 +268,8 @@ export function buildFilterQuery (filter = {}) {
273
268
  if (query[query.length - 1] === '*') return `${f}:~^'${query.replaceAll('*', '')}'`
274
269
  return `${f}:~'${query.replaceAll('*', '')}'`
275
270
  })
276
- if (parts.length === 1) q = nql(parts[0]).parse()
277
- else if (parts.length > 1) q = nql(parts.join(',')).parse()
271
+ if (parts.length === 1) q = parseNql(parts[0])
272
+ else if (parts.length > 1) q = parseNql(parts.join(','))
278
273
  }
279
274
  } catch (err) {
280
275
  this.plugin.error('invalidQuery', { orgMessage: err.message })
@@ -366,16 +361,10 @@ export function buildFilterSearch (filter = {}) {
366
361
  }
367
362
 
368
363
  export function replaceIdInQuerySearch (filter) {
369
- // query
370
- const query = JSON.stringify(filter.query ?? {}, (key, value) => {
371
- if (value instanceof RegExp) return ['__REGEXP__', value.source, value.flags]
372
- return value
373
- }).replaceAll('"id"', `"${this.driver.idField.name}"`)
364
+ const { replaceRegexInJson, reviveRegexInJson } = this.app.dobo
365
+ const query = replaceRegexInJson(filter.query).replaceAll('"id"', `"${this.driver.idField.name}"`)
374
366
  try {
375
- filter.query = JSON.parse(query, (key, value) => {
376
- if (Array.isArray(value) && value[0] === '__REGEXP__') return new RegExp(value[1], value[2])
377
- return value
378
- })
367
+ filter.query = reviveRegexInJson(query)
379
368
  } catch (err) {}
380
369
  // search
381
370
  const search = JSON.stringify(filter.search ?? {}).replaceAll('"id"', `"${this.driver.idField.name}"`)
@@ -7,17 +7,17 @@ async function createAttachment (...args) {
7
7
  const [id, opts = {}] = args
8
8
  const { fs } = this.app.lib
9
9
  const { isEmpty } = this.app.lib._
10
- const { source, fieldName = 'file', file, fullPath, stats, mimeType, req } = opts
10
+ const { source, field = 'file', file, fullPath, stats, mimeType, req } = opts
11
11
  if (isEmpty(file)) return
12
12
  if (!source) throw this.plugin.error('isMissing%s', this.plugin.t('field.source'))
13
- const baseDir = await getAttachmentPath.call(this, id, fieldName, file, { dirOnly: true })
14
- let dir = `${baseDir}/${fieldName}`
15
- if ((fieldName || '').endsWith('[]')) dir = `${baseDir}/${fieldName.replace('[]', '')}`
13
+ const baseDir = await getAttachmentPath.call(this, id, field, file, { dirOnly: true })
14
+ let dir = `${baseDir}/${field}`
15
+ if ((field || '').endsWith('[]')) dir = `${baseDir}/${field.replace('[]', '')}`
16
16
  const dest = `${dir}/${file}`.replaceAll('//', '/')
17
17
  await fs.ensureDir(dir)
18
18
  await fs.copy(source, dest)
19
19
  const rec = {
20
- field: fieldName === '' ? undefined : fieldName,
20
+ field: field === '' ? undefined : field,
21
21
  dir,
22
22
  file
23
23
  }
@@ -14,12 +14,12 @@ async function findAttachment (...args) {
14
14
  const recs = []
15
15
  for (const f of files) {
16
16
  const item = f.replace(dir, '')
17
- let [, fieldName, file] = item.split('/')
17
+ let [, field, file] = item.split('/')
18
18
  if (!file) {
19
- file = fieldName
20
- fieldName = null
19
+ file = field
20
+ field = null
21
21
  }
22
- const rec = { fieldName, file }
22
+ const rec = { field, file }
23
23
  await mergeAttachmentInfo.call(this, rec, f, { mimeType, fullPath, stats })
24
24
  recs.push(rec)
25
25
  }
@@ -3,11 +3,11 @@ const action = 'getAttachment'
3
3
  async function getAttachment (...args) {
4
4
  if (!this.attachment) return
5
5
  if (args.length === 0) return this.action(action, ...args)
6
- let [id, fieldName, file, opts = {}] = args
6
+ let [id, field, file, opts = {}] = args
7
7
  const { find } = this.app.lib._
8
8
  const all = await this.findAttachment(id, opts)
9
- if (fieldName === 'null') fieldName = null
10
- const data = find(all, { fieldName, file })
9
+ if (field === 'null') field = null
10
+ const data = find(all, { field, file })
11
11
  if (!data) throw this.error('notFound', { statusCode: 404 })
12
12
  return data
13
13
  }
@@ -10,10 +10,10 @@ async function listAttachment (...args) {
10
10
  const mime = await importPkg('waibu:mime')
11
11
  const { fastGlob } = this.app.lib
12
12
 
13
- const { id = '*', fieldName = '*', file = '*', type } = params
13
+ const { id = '*', field = '*', file = '*', type } = params
14
14
  const { uriEncoded = true } = opts
15
15
  const root = `${getPluginDataDir('dobo')}/attachment`
16
- let pattern = `${root}/${this.name}/${id}/${fieldName}/${file}`
16
+ let pattern = `${root}/${this.name}/${id}/${field}/${file}`
17
17
  if (type === 'image') pattern += '.{jpg,jpeg,gif,png,webp,avif,svg}'
18
18
  else if (type === 'video') pattern += '.{mp4,m4v,webm,mov,qt,mkv,ogg,ogv}'
19
19
  else if (type) pattern += `.${type}`
@@ -26,12 +26,12 @@ async function listAttachment (...args) {
26
26
  fileName: path.basename(fullPath),
27
27
  fullPath,
28
28
  mimeType,
29
- params: { model: this.name, id, fieldName, file }
29
+ params: { model: this.name, id, field, file }
30
30
  }
31
31
  if (this.app.waibuMpa) {
32
32
  const { routePath } = this.app.waibu
33
- const [, _model, _id, _fieldName, _file] = fullPath.split('/')
34
- row.url = routePath(`dobo:/attachment/${kebabCase(_model)}/${_id}/${_fieldName}/${_file}`)
33
+ const [, _model, _id, _field, _file] = fullPath.split('/')
34
+ row.url = routePath(`dobo:/attachment/${kebabCase(_model)}/${_id}/${_field}/${_file}`)
35
35
  }
36
36
  return row
37
37
  })
@@ -4,9 +4,9 @@ const action = 'removeAttachment'
4
4
  async function removeAttachment (...args) {
5
5
  if (!this.attachment) return
6
6
  if (args.length === 0) return this.action(action, ...args)
7
- const [id, fieldName, file, opts = {}] = args
7
+ const [id, field, file, opts = {}] = args
8
8
  const { fs } = this.app.lib
9
- const path = await getAttachmentPath.call(this, id, fieldName, file)
9
+ const path = await getAttachmentPath.call(this, id, field, file)
10
10
  const { req } = opts
11
11
  await fs.remove(path)
12
12
  if (!opts.noFlash && req && req.flash) req.flash('notify', req.t('attachmentRemoved'))
@@ -11,8 +11,9 @@
11
11
  */
12
12
  async function sanitizeRecord (record = {}, opts = {}) {
13
13
  const { fields = [], hidden = [], forceNoHidden } = opts
14
- const { isEmpty, map, without, isArray } = this.app.lib._
14
+ const { isEmpty, map, without, isArray, isFunction, isString, get, cloneDeep } = this.app.lib._
15
15
  const { fillObject } = this.app.lib.aneka
16
+ const { callHandler, format } = this.app.bajo
16
17
  let allHidden = without([...this.hidden, ...hidden], 'id')
17
18
  if (forceNoHidden === true) allHidden = []
18
19
  else if (isArray(forceNoHidden)) allHidden = without(allHidden, ...forceNoHidden)
@@ -22,6 +23,24 @@ async function sanitizeRecord (record = {}, opts = {}) {
22
23
  newFields = without(newFields, ...allHidden)
23
24
  const body = fillObject(record, newFields, null)
24
25
  const newRecord = await this.sanitizeBody({ body, noDefault: true })
26
+ if (opts.formatValue) {
27
+ if (opts.retainOriginalValue) newRecord._orig = cloneDeep(newRecord)
28
+ for (const key in newRecord) {
29
+ const prop = this.getProperty(key)
30
+ if (!prop) continue
31
+ if (prop.format) {
32
+ if (isFunction(prop.format)) newRecord[key] = await prop.format.call(this, newRecord[key], newRecord, opts)
33
+ else if (isString(prop.format)) newRecord[key] = await callHandler(this.plugin, this, newRecord[key], newRecord, opts)
34
+ } else {
35
+ const options = {
36
+ lang: get(opts, 'req.lang'),
37
+ latitude: ['lat', 'latitude'].includes(key),
38
+ longitude: ['lon', 'lng', 'longitude'].includes(key)
39
+ }
40
+ newRecord[key] = format(newRecord[key], prop.type, options)
41
+ }
42
+ }
43
+ }
25
44
  if (record._ref) newRecord._ref = record._ref
26
45
  return newRecord
27
46
  }
@@ -160,8 +160,8 @@ async function modelFactory () {
160
160
  getField = (name) => this.getProperty(name)
161
161
  hasField = (name) => this.hasProperty(name)
162
162
 
163
- dispose () {
164
- super.dispose()
163
+ dispose = async () => {
164
+ await super.dispose()
165
165
  this.connection = null
166
166
  this.driver = null
167
167
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dobo",
3
- "version": "2.14.1",
3
+ "version": "2.16.0",
4
4
  "description": "DBMS for Bajo Framework",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/wiki/CHANGES.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changes
2
2
 
3
+ ## 2026-04-11
4
+
5
+ - [2.16.0] Add ```format``` as new model's property key
6
+ - [2.16.0] Rewrite ```getDefaultValues()``` to base on ```req.getSetting()```
7
+ - [2.16.0] All inter site admins are now exempts from ```immutable``` row
8
+ - [2.16.0] Bug fix in ```collect-models.js```
9
+ - [2.16.0] Bug fix in ```getSingleRef()``` and ```getMultiRefs()```
10
+ - [2.16.0] Add feature to return formatted row(s) with ```options.formatValue```
11
+ - [2.16.0] If row is formatted, add feature to save original row in ```_orig``` with ```options.retainOriginalValue```
12
+
13
+ ## 2026-04-07
14
+
15
+ - [2.15.0] Add ```parseNql()```
16
+ - [2.15.0] Add ```parseQuery()```
17
+ - [2.15.0] Add ```replaceRegexInJson()```
18
+ - [2.15.0] Add ```reviveRegexInJson()```
19
+ - [2.15.0] Change all ```opts.fieldName``` to ```opts.field``` in features
20
+ - [2.15.0] Add ```ref.searchField``` in model reference
21
+ - [2.15.0] Add ```ref.labelField``` in model reference
22
+ - [2.15.0] Add ```ref.valueField``` in model reference
23
+ - [2.15.0] change ```ref.propName``` to ```ref.field``` in model reference
24
+
25
+ ## 2026-04-02
26
+
27
+ - [2.14.1] Bug fix in ```hardCap```
28
+ - [2.14.1] ```warnings``` now can be turned off through ```config``` or site settings
29
+
3
30
  ## 2026-04-01
4
31
 
5
32
  - [2.14.0] Add ```between``` as custom query, since it doesn't exists in NQL