dobo 2.34.1 → 2.36.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.
- package/lib/factory/adapter.js +12 -11
- package/lib/factory/model/sanitize-record.js +18 -13
- package/package.json +5 -3
- package/test/e2e/_run.js +12 -0
- package/test/e2e/e2e-factory-process.test.js +25 -0
- package/test/e2e/e2e-model-adapter-process.test.js +52 -0
- package/test/integration/aspect-01-start-connections.test.js +33 -0
- package/test/integration/aspect-02-query-regex.test.js +34 -0
- package/test/integration/aspect-03-model-adapter-flow.test.js +88 -0
- package/test/unit/_stub.js +244 -0
- package/test/unit/dobo-action.test.js +58 -0
- package/test/unit/dobo-connection.test.js +37 -0
- package/test/unit/dobo-core.test.js +136 -0
- package/test/unit/dobo-feature.test.js +25 -0
- package/test/unit/dobo-model.test.js +115 -0
- package/test/unit/helper-sanitize-all.test.js +40 -0
- package/test/unit/helper-sanitize-ref.test.js +64 -0
- package/wiki/CHANGES.md +10 -0
package/lib/factory/adapter.js
CHANGED
|
@@ -175,10 +175,11 @@ async function adapterFactory () {
|
|
|
175
175
|
* Sanitizes a record retrieved from the database, converting data types as necessary
|
|
176
176
|
* and ensuring that the record conforms to the model's schema.
|
|
177
177
|
* @param {DoboModel} model - The model instance for which the record is being sanitized
|
|
178
|
-
* @param {object} record - The record retrieved from the database
|
|
178
|
+
* @param {object} [record={}] - The record retrieved from the database
|
|
179
|
+
* @param {object} [options={}] - Additional options for sanitization
|
|
179
180
|
* @returns {object} - Sanitized record
|
|
180
181
|
*/
|
|
181
|
-
sanitizeRecord (model, record = {}) {
|
|
182
|
+
sanitizeRecord (model, record = {}, options = {}) {
|
|
182
183
|
const { dayjs } = this.app.lib
|
|
183
184
|
const { isString } = this.app.lib._
|
|
184
185
|
const item = { ...record }
|
|
@@ -461,7 +462,7 @@ async function adapterFactory () {
|
|
|
461
462
|
await this._attachHook('afterCreateRecord', model, body, result, options)
|
|
462
463
|
|
|
463
464
|
if (options.noResult) return
|
|
464
|
-
result.data = this.sanitizeRecord(model, result.data)
|
|
465
|
+
result.data = this.sanitizeRecord(model, result.data, options)
|
|
465
466
|
this._injectMeta(result, options)
|
|
466
467
|
return result
|
|
467
468
|
}
|
|
@@ -512,7 +513,7 @@ async function adapterFactory () {
|
|
|
512
513
|
await this._attachHook('afterGetRecord', model, id, result, options)
|
|
513
514
|
|
|
514
515
|
if (isEmpty(result.data) && options.throwNotFound) throw this.plugin.error('recordNotFound%s%s', id, model.name)
|
|
515
|
-
result.data = this.sanitizeRecord(model, result.data)
|
|
516
|
+
result.data = this.sanitizeRecord(model, result.data, options)
|
|
516
517
|
this._injectMeta(result, options)
|
|
517
518
|
return result
|
|
518
519
|
}
|
|
@@ -547,8 +548,8 @@ async function adapterFactory () {
|
|
|
547
548
|
await this._attachHook('afterUpdateRecord', model, id, body, result, options)
|
|
548
549
|
|
|
549
550
|
if (options.noResult) return
|
|
550
|
-
result.oldData = this.sanitizeRecord(model, result.oldData)
|
|
551
|
-
result.data = this.sanitizeRecord(model, result.data)
|
|
551
|
+
result.oldData = this.sanitizeRecord(model, result.oldData, options)
|
|
552
|
+
result.data = this.sanitizeRecord(model, result.data, options)
|
|
552
553
|
this._injectMeta(result, options)
|
|
553
554
|
return result
|
|
554
555
|
}
|
|
@@ -584,8 +585,8 @@ async function adapterFactory () {
|
|
|
584
585
|
await this._attachHook('afterUpsertRecord', model, body, result, options)
|
|
585
586
|
|
|
586
587
|
if (options.noResult) return
|
|
587
|
-
if (result.oldData) result.oldData = this.sanitizeRecord(model, result.oldData)
|
|
588
|
-
result.data = this.sanitizeRecord(model, result.data)
|
|
588
|
+
if (result.oldData) result.oldData = this.sanitizeRecord(model, result.oldData, options)
|
|
589
|
+
result.data = this.sanitizeRecord(model, result.data, options)
|
|
589
590
|
this._injectMeta(result, options)
|
|
590
591
|
return result
|
|
591
592
|
}
|
|
@@ -613,7 +614,7 @@ async function adapterFactory () {
|
|
|
613
614
|
await this._attachHook('afterRemoveRecord', model, id, result, options)
|
|
614
615
|
|
|
615
616
|
if (options.noResult) return
|
|
616
|
-
result.oldData = this.sanitizeRecord(model, result.oldData)
|
|
617
|
+
result.oldData = this.sanitizeRecord(model, result.oldData, options)
|
|
617
618
|
this._injectMeta(result, options)
|
|
618
619
|
return result
|
|
619
620
|
}
|
|
@@ -664,7 +665,7 @@ async function adapterFactory () {
|
|
|
664
665
|
}
|
|
665
666
|
|
|
666
667
|
for (const idx in result.data) {
|
|
667
|
-
result.data[idx] = this.sanitizeRecord(model, result.data[idx])
|
|
668
|
+
result.data[idx] = this.sanitizeRecord(model, result.data[idx], options)
|
|
668
669
|
}
|
|
669
670
|
this._injectMeta(result, options)
|
|
670
671
|
return result
|
|
@@ -698,7 +699,7 @@ async function adapterFactory () {
|
|
|
698
699
|
}
|
|
699
700
|
|
|
700
701
|
for (const idx in result.data) {
|
|
701
|
-
result.data[idx] = this.sanitizeRecord(model, result.data[idx])
|
|
702
|
+
result.data[idx] = this.sanitizeRecord(model, result.data[idx], options)
|
|
702
703
|
}
|
|
703
704
|
this._injectMeta(result, options)
|
|
704
705
|
return result
|
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
* @param {boolean} [opts.forceNoHidden=false] - If `true`, force ALL fields to be picked, thus ignoring hidden fields. If an array, force all fields except those in the array to be picked.
|
|
11
11
|
* @param {boolean} [opts.fmt=false] - If `true`, add a `_fmt` property to the sanitized record with formatted values automatically based on the model's properties and rules
|
|
12
12
|
* @param {Object} [opts.req] - Request object, used for formatting values based on request context
|
|
13
|
-
* @param {Object} [opts.
|
|
14
|
-
* @param {string} [opts.
|
|
15
|
-
* @param {string} [opts.
|
|
16
|
-
* @param {string} [opts.
|
|
17
|
-
* @param {string} [opts.
|
|
13
|
+
* @param {Object} [opts.intl] - Internationalization options, used for formatting values based on i18n context
|
|
14
|
+
* @param {string} [opts.intl.lang] - Language code for formatting values
|
|
15
|
+
* @param {string} [opts.intl.datetime] - Date and time style for formatting datetime values
|
|
16
|
+
* @param {string} [opts.intl.date] - Date style for formatting date values
|
|
17
|
+
* @param {string} [opts.intl.time] - Time style for formatting time values
|
|
18
|
+
* @param {string} [opts.intl.timeZone] - Time zone for formatting date/time values
|
|
18
19
|
* @returns {Object} Returns sanitized record object
|
|
19
20
|
*/
|
|
20
21
|
async function sanitizeRecord (record = {}, opts = {}) {
|
|
@@ -42,6 +43,14 @@ async function sanitizeRecord (record = {}, opts = {}) {
|
|
|
42
43
|
if (opts.fmt) {
|
|
43
44
|
newRecord._fmt = cloneDeep(newRecord)
|
|
44
45
|
delete newRecord._fmt._ref
|
|
46
|
+
const options = {
|
|
47
|
+
lang: get(opts, 'intl.lang', get(opts, 'req.lang')),
|
|
48
|
+
timeZone: get(opts, 'intl.timeZone', get(opts, 'req.site.setting.bajo.intl.timeZone')),
|
|
49
|
+
emptyValue: get(opts, 'intl.emptyValue', get(opts, 'req.site.setting.bajo.intl.emptyValue')),
|
|
50
|
+
unitSys: get(opts, 'intl.unitSys', get(opts, 'req.site.setting.bajo.intl.unitSys')),
|
|
51
|
+
formatter: get(opts, 'intl.formatter', get(opts, 'req.site.setting.bajo.intl.formatter'))
|
|
52
|
+
}
|
|
53
|
+
|
|
45
54
|
for (const key in newRecord) {
|
|
46
55
|
const prop = this.getProperty(key)
|
|
47
56
|
if (!prop) continue
|
|
@@ -55,17 +64,13 @@ async function sanitizeRecord (record = {}, opts = {}) {
|
|
|
55
64
|
else if (isFunction(prop.format)) newRecord._fmt[key] = await prop.format.call(this, value, newRecord, opts)
|
|
56
65
|
else if (isString(prop.format)) newRecord._fmt[key] = await callHandler(this.plugin, this, value, newRecord, opts)
|
|
57
66
|
else {
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
dateStyle: get(opts, 'i18n.dateStyle', get(opts, 'req.site.setting.sumba.dateStyle')),
|
|
62
|
-
timeStyle: get(opts, 'i18n.timeStyle', get(opts, 'req.site.setting.sumba.timeStyle')),
|
|
63
|
-
timeZone: get(opts, 'i18n.timeZone', get(opts, 'req.site.setting.sumba.timeZone'))
|
|
64
|
-
},
|
|
67
|
+
const _options = {
|
|
68
|
+
...options,
|
|
69
|
+
field: key,
|
|
65
70
|
latitude: ['lat', 'latitude'].includes(key),
|
|
66
71
|
longitude: ['lon', 'lng', 'longitude'].includes(key)
|
|
67
72
|
}
|
|
68
|
-
newRecord._fmt[key] = format(value, prop.type,
|
|
73
|
+
newRecord._fmt[key] = format(value, prop.type, _options)
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dobo",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.36.0",
|
|
4
4
|
"description": "DBMS for Bajo Framework",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build-doc": "jsdoc -c .jsdoc.conf.json",
|
|
8
|
-
"test": "mocha"
|
|
8
|
+
"test": "mocha --recursive"
|
|
9
9
|
},
|
|
10
10
|
"type": "module",
|
|
11
11
|
"bajo": {
|
|
@@ -39,7 +39,9 @@
|
|
|
39
39
|
"uuid": "^13.0.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
+
"chai": "^6.2.2",
|
|
42
43
|
"clean-jsdoc-theme": "^4.3.0",
|
|
43
|
-
"jsdoc-plugin-intersection": "^1.0.4"
|
|
44
|
+
"jsdoc-plugin-intersection": "^1.0.4",
|
|
45
|
+
"mocha": "^11.7.6"
|
|
44
46
|
}
|
|
45
47
|
}
|
package/test/e2e/_run.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export const runNodeInline = async (code, cwd) => {
|
|
4
|
+
return await new Promise((resolve) => {
|
|
5
|
+
const child = spawn(process.execPath, ['--input-type=module', '-e', code], { cwd, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
6
|
+
let stdout = ''
|
|
7
|
+
let stderr = ''
|
|
8
|
+
child.stdout.on('data', d => { stdout += d.toString() })
|
|
9
|
+
child.stderr.on('data', d => { stderr += d.toString() })
|
|
10
|
+
child.on('close', (exitCode) => resolve({ code: exitCode, stdout, stderr }))
|
|
11
|
+
})
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/* global describe, it */
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai'
|
|
4
|
+
import { runNodeInline } from './_run.js'
|
|
5
|
+
|
|
6
|
+
describe('e2e factory process', () => {
|
|
7
|
+
it('builds dobo class and executes runtime methods in separate process', async function () {
|
|
8
|
+
this.timeout(10000)
|
|
9
|
+
|
|
10
|
+
const code = `
|
|
11
|
+
import factory from './index.js'
|
|
12
|
+
import { createAppStub } from './test/unit/_stub.js'
|
|
13
|
+
const app = createAppStub('/tmp/dobo-e2e')
|
|
14
|
+
const Dobo = await factory.call({ app }, 'dobo')
|
|
15
|
+
const dobo = new Dobo()
|
|
16
|
+
const rows = [{ g: 'a', n: 1 }, { g: 'a', n: 3 }]
|
|
17
|
+
const agg = dobo.calcAggregate({ data: rows, group: 'g', field: 'n', aggregates: ['count', 'avg'] })
|
|
18
|
+
console.log('E2E_OK:' + (Array.isArray(agg) && agg.length === 1 && agg[0].count === 2))
|
|
19
|
+
`
|
|
20
|
+
|
|
21
|
+
const res = await runNodeInline(code, '/mnt/d/Projects/Dobo/dobo')
|
|
22
|
+
expect(res.code).to.equal(0)
|
|
23
|
+
expect(res.stdout).to.include('E2E_OK:true')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/* global describe, it */
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai'
|
|
4
|
+
import { runNodeInline } from './_run.js'
|
|
5
|
+
|
|
6
|
+
describe('e2e model-adapter process', () => {
|
|
7
|
+
it('creates model and adapter classes and runs wrapper calls in separate process', async function () {
|
|
8
|
+
this.timeout(12000)
|
|
9
|
+
|
|
10
|
+
const code = `
|
|
11
|
+
import adapterFactory from './lib/factory/adapter.js'
|
|
12
|
+
import modelFactory from './lib/factory/model.js'
|
|
13
|
+
import { createAppStub, Base } from './test/unit/_stub.js'
|
|
14
|
+
|
|
15
|
+
const app = createAppStub('/tmp/dobo-e2e-model-adapter')
|
|
16
|
+
app.bajo.runHook = async () => {}
|
|
17
|
+
app.dobo = {
|
|
18
|
+
ns: 'dobo',
|
|
19
|
+
t: (x) => x,
|
|
20
|
+
error: (m) => new Error(m),
|
|
21
|
+
checkAggregateParams: () => {},
|
|
22
|
+
checkHistogramParams: () => {}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const plugin = new Base('dobo', app)
|
|
26
|
+
plugin.error = (m) => new Error(m)
|
|
27
|
+
await adapterFactory.call(plugin)
|
|
28
|
+
await modelFactory.call(plugin)
|
|
29
|
+
|
|
30
|
+
const adapter = new app.baseClass.DoboAdapter(plugin, 'memory')
|
|
31
|
+
const conn = { name: 'default', adapter }
|
|
32
|
+
const model = new app.baseClass.DoboModel(plugin, {
|
|
33
|
+
name: 'Order',
|
|
34
|
+
connection: conn,
|
|
35
|
+
properties: [{ name: 'id', type: 'string', maxLength: 50 }, { name: 'name', type: 'string' }],
|
|
36
|
+
indexes: [{ name: 'uidx_id', type: 'unique', fields: ['id'] }],
|
|
37
|
+
hooks: []
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
adapter.createRecord = async (m, body) => ({ data: { ...body } })
|
|
41
|
+
adapter.getRecord = async (m, id) => ({ data: { _id: id, name: 'x' } })
|
|
42
|
+
|
|
43
|
+
const created = await adapter._createRecord(model, { id: 'e1', name: 'ok' }, { noUniqueCheck: true, noIdCheck: true })
|
|
44
|
+
const got = await adapter._getRecord(model, 'e1', {})
|
|
45
|
+
console.log('E2E_MODEL_ADAPTER_OK:' + (created.data.id === 'e1' && got.data.id === 'e1'))
|
|
46
|
+
`
|
|
47
|
+
|
|
48
|
+
const res = await runNodeInline(code, '/mnt/d/Projects/Dobo/dobo')
|
|
49
|
+
expect(res.code).to.equal(0)
|
|
50
|
+
expect(res.stdout).to.include('E2E_MODEL_ADAPTER_OK:true')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/* global describe, it, beforeEach */
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai'
|
|
4
|
+
import factory from '../../index.js'
|
|
5
|
+
import { createAppStub } from '../unit/_stub.js'
|
|
6
|
+
|
|
7
|
+
describe('integration aspect 01 - start connections', () => {
|
|
8
|
+
let dobo
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
const app = createAppStub('/tmp/dobo-int-01')
|
|
12
|
+
const Dobo = await factory.call({ app }, 'dobo')
|
|
13
|
+
dobo = new Dobo()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('starts all, single, and selected connections', async () => {
|
|
17
|
+
const calls = []
|
|
18
|
+
const c1 = { name: 'primary', connect: async () => calls.push('primary'), adapter: { plugin: { ns: 'dobo' }, name: 'memory' } }
|
|
19
|
+
const c2 = { name: 'audit', connect: async () => calls.push('audit'), adapter: { plugin: { ns: 'dobo' }, name: 'memory' } }
|
|
20
|
+
dobo.connections = [c1, c2]
|
|
21
|
+
|
|
22
|
+
await dobo.start('all')
|
|
23
|
+
expect(calls).to.deep.equal(['primary', 'audit'])
|
|
24
|
+
|
|
25
|
+
calls.length = 0
|
|
26
|
+
await dobo.start('audit')
|
|
27
|
+
expect(calls).to.deep.equal(['audit'])
|
|
28
|
+
|
|
29
|
+
calls.length = 0
|
|
30
|
+
await dobo.start(['primary'])
|
|
31
|
+
expect(calls).to.deep.equal(['primary'])
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* global describe, it, beforeEach */
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai'
|
|
4
|
+
import factory from '../../index.js'
|
|
5
|
+
import { createAppStub } from '../unit/_stub.js'
|
|
6
|
+
|
|
7
|
+
describe('integration aspect 02 - query and regex', () => {
|
|
8
|
+
let dobo
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
const app = createAppStub('/tmp/dobo-int-02')
|
|
12
|
+
const Dobo = await factory.call({ app }, 'dobo')
|
|
13
|
+
dobo = new Dobo()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('parses query text and preserves regex via json conversion', () => {
|
|
17
|
+
const model = {
|
|
18
|
+
scanables: ['title'],
|
|
19
|
+
sortables: ['title'],
|
|
20
|
+
properties: [{ name: 'title', type: 'string' }],
|
|
21
|
+
adapter: { idField: { name: '_id' } }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const q1 = dobo.parseQuery('title:~\'book\'', model)
|
|
25
|
+
expect(q1).to.be.an('object')
|
|
26
|
+
|
|
27
|
+
const q2 = dobo.parseQuery('{"id":"x"}', model)
|
|
28
|
+
expect(q2._id).to.equal('x')
|
|
29
|
+
|
|
30
|
+
const str = dobo.replaceRegexInJson({ title: /book/i })
|
|
31
|
+
const obj = dobo.reviveRegexInJson(str)
|
|
32
|
+
expect(obj.title).to.be.instanceOf(RegExp)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/* global describe, it, beforeEach */
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai'
|
|
4
|
+
import adapterFactory from '../../lib/factory/adapter.js'
|
|
5
|
+
import modelFactory from '../../lib/factory/model.js'
|
|
6
|
+
import { createAppStub, Base } from '../unit/_stub.js'
|
|
7
|
+
|
|
8
|
+
describe('integration aspect 03 - model adapter flow', () => {
|
|
9
|
+
let app
|
|
10
|
+
let plugin
|
|
11
|
+
let model
|
|
12
|
+
let adapter
|
|
13
|
+
const store = {}
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
app = createAppStub('/tmp/dobo-int-03')
|
|
17
|
+
app.bajo.runHook = async () => {}
|
|
18
|
+
app.dobo = {
|
|
19
|
+
ns: 'dobo',
|
|
20
|
+
t: (x) => x,
|
|
21
|
+
error: (msg, payload) => {
|
|
22
|
+
const err = new Error(msg)
|
|
23
|
+
err.payload = payload
|
|
24
|
+
return err
|
|
25
|
+
},
|
|
26
|
+
getDefaultValues: () => ({ warnings: true }),
|
|
27
|
+
checkAggregateParams: () => {},
|
|
28
|
+
checkHistogramParams: () => {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
plugin = new Base('dobo', app)
|
|
32
|
+
plugin.error = (msg, ...args) => new Error(`${msg}:${args.join(',')}`)
|
|
33
|
+
await adapterFactory.call(plugin)
|
|
34
|
+
await modelFactory.call(plugin)
|
|
35
|
+
|
|
36
|
+
adapter = new app.baseClass.DoboAdapter(plugin, 'memory')
|
|
37
|
+
const conn = { name: 'default', adapter }
|
|
38
|
+
|
|
39
|
+
model = new app.baseClass.DoboModel(plugin, {
|
|
40
|
+
name: 'Order',
|
|
41
|
+
connection: conn,
|
|
42
|
+
properties: [
|
|
43
|
+
{ name: 'id', type: 'string', maxLength: 50 },
|
|
44
|
+
{ name: 'name', type: 'string' },
|
|
45
|
+
{ name: 'amount', type: 'double' },
|
|
46
|
+
{ name: 'createdAt', type: 'datetime' }
|
|
47
|
+
],
|
|
48
|
+
indexes: [{ name: 'uidx_id', type: 'unique', fields: ['id'] }],
|
|
49
|
+
hooks: []
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
adapter.getRecord = async (m, id) => ({ data: store[id] ? { ...store[id] } : null })
|
|
53
|
+
adapter.createRecord = async (m, body) => {
|
|
54
|
+
const key = body._id ?? body.id
|
|
55
|
+
store[key] = { ...body }
|
|
56
|
+
return { data: { ...body } }
|
|
57
|
+
}
|
|
58
|
+
adapter.updateRecord = async (m, id, body) => {
|
|
59
|
+
const oldData = { ...(store[id] ?? {}) }
|
|
60
|
+
store[id] = { ...(store[id] ?? {}), ...body }
|
|
61
|
+
return { oldData, data: { ...store[id] } }
|
|
62
|
+
}
|
|
63
|
+
adapter.findRecord = async () => ({ data: Object.values(store), count: Object.keys(store).length })
|
|
64
|
+
adapter.removeRecord = async (m, id) => {
|
|
65
|
+
const oldData = store[id] ? { ...store[id] } : null
|
|
66
|
+
delete store[id]
|
|
67
|
+
return { oldData }
|
|
68
|
+
}
|
|
69
|
+
adapter.clearRecord = async () => {
|
|
70
|
+
for (const k in store) delete store[k]
|
|
71
|
+
return { data: true }
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('executes create/find/update/remove lifecycle through adapter wrappers', async () => {
|
|
76
|
+
const c = await adapter._createRecord(model, { id: 'o1', name: 'book', amount: 10 }, { noUniqueCheck: true, noIdCheck: true })
|
|
77
|
+
expect(c.data.id).to.equal('o1')
|
|
78
|
+
|
|
79
|
+
const f = await adapter._findRecord(model, {}, {})
|
|
80
|
+
expect(f.count).to.equal(1)
|
|
81
|
+
|
|
82
|
+
const u = await adapter._updateRecord(model, 'o1', { amount: 15 }, { _data: { ...store.o1 } })
|
|
83
|
+
expect(u.data.amount).to.equal(15)
|
|
84
|
+
|
|
85
|
+
const r = await adapter._removeRecord(model, 'o1', { _data: { ...store.o1 } })
|
|
86
|
+
expect(r.oldData.id).to.equal('o1')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
export const isPlainObject = (v) => Object.prototype.toString.call(v) === '[object Object]'
|
|
4
|
+
|
|
5
|
+
export const camelCase = (s = '') => {
|
|
6
|
+
return String(s)
|
|
7
|
+
.replace(/^[\s_-]+|[\s_-]+$/g, '')
|
|
8
|
+
.split(/[\s_.-]+/)
|
|
9
|
+
.map((p, i) => i === 0 ? (p[0] ? p[0].toLowerCase() + p.slice(1) : '') : (p[0] ? p[0].toUpperCase() + p.slice(1) : ''))
|
|
10
|
+
.join('')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const pascalCase = (s = '') => {
|
|
14
|
+
const cc = camelCase(s)
|
|
15
|
+
return cc[0] ? cc[0].toUpperCase() + cc.slice(1) : cc
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const find = (arr = [], matcher = {}) => {
|
|
19
|
+
if (typeof matcher === 'function') return arr.find(matcher)
|
|
20
|
+
return arr.find(item => Object.keys(matcher).every(k => item?.[k] === matcher[k]))
|
|
21
|
+
}
|
|
22
|
+
export const filter = (arr = [], matcher = {}) => {
|
|
23
|
+
if (typeof matcher === 'function') return arr.filter(matcher)
|
|
24
|
+
return arr.filter(item => Object.keys(matcher).every(k => item?.[k] === matcher[k]))
|
|
25
|
+
}
|
|
26
|
+
export const map = (arr = [], fn) => {
|
|
27
|
+
if (typeof fn === 'function') return arr.map(fn)
|
|
28
|
+
if (typeof fn === 'string') return arr.map(item => item?.[fn])
|
|
29
|
+
return [...arr]
|
|
30
|
+
}
|
|
31
|
+
export const pick = (obj = {}, keys = []) => keys.reduce((a, k) => { if (Object.prototype.hasOwnProperty.call(obj, k)) a[k] = obj[k]; return a }, {})
|
|
32
|
+
export const groupBy = (arr = [], key) => arr.reduce((a, item) => { const k = item?.[key]; a[k] = a[k] ?? []; a[k].push(item); return a }, {})
|
|
33
|
+
export const isEmpty = (v) => {
|
|
34
|
+
if (v === null || v === undefined) return true
|
|
35
|
+
if (typeof v === 'string') return v.trim() === ''
|
|
36
|
+
if (Array.isArray(v)) return v.length === 0
|
|
37
|
+
if (isPlainObject(v)) return Object.keys(v).length === 0
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
export const uniq = (arr = []) => [...new Set(arr)]
|
|
41
|
+
export const without = (arr = [], ...vals) => {
|
|
42
|
+
const flat = vals.flat()
|
|
43
|
+
return arr.filter(item => !flat.includes(item))
|
|
44
|
+
}
|
|
45
|
+
export const trim = (s = '') => String(s).trim()
|
|
46
|
+
export const has = (obj, k) => Object.prototype.hasOwnProperty.call(obj ?? {}, k)
|
|
47
|
+
export const keys = (obj = {}) => Object.keys(obj)
|
|
48
|
+
export const defaults = (obj = {}, src = {}) => {
|
|
49
|
+
for (const k of Object.keys(src)) if (obj[k] === undefined) obj[k] = src[k]
|
|
50
|
+
return obj
|
|
51
|
+
}
|
|
52
|
+
export const isFunction = (v) => typeof v === 'function'
|
|
53
|
+
export const kebabCase = (s = '') => String(s).replace(/([a-z])([A-Z])/g, '$1-$2').replace(/[\s_.]+/g, '-').toLowerCase()
|
|
54
|
+
export const chunk = (arr = [], size = 1) => {
|
|
55
|
+
const out = []
|
|
56
|
+
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
export const last = (arr = []) => arr[arr.length - 1]
|
|
60
|
+
export const forOwn = (obj = {}, fn) => {
|
|
61
|
+
for (const k of Object.keys(obj)) fn(obj[k], k)
|
|
62
|
+
}
|
|
63
|
+
export const findIndex = (arr = [], matcher = {}) => {
|
|
64
|
+
if (typeof matcher === 'function') return arr.findIndex(matcher)
|
|
65
|
+
return arr.findIndex(item => Object.keys(matcher).every(k => item?.[k] === matcher[k]))
|
|
66
|
+
}
|
|
67
|
+
export const omit = (obj = {}, drop = []) => {
|
|
68
|
+
const d = Array.isArray(drop) ? drop : [drop]
|
|
69
|
+
const out = {}
|
|
70
|
+
for (const k in obj) if (!d.includes(k)) out[k] = obj[k]
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
export const cloneDeep = (obj) => JSON.parse(JSON.stringify(obj))
|
|
74
|
+
export const pullAt = (arr = [], indexes = []) => {
|
|
75
|
+
const idxs = [...indexes].sort((a, b) => b - a)
|
|
76
|
+
for (const idx of idxs) if (Number.isInteger(idx) && idx >= 0 && idx < arr.length) arr.splice(idx, 1)
|
|
77
|
+
return arr
|
|
78
|
+
}
|
|
79
|
+
export const orderBy = (arr = [], fields = []) => {
|
|
80
|
+
const [field] = fields
|
|
81
|
+
return [...arr].sort((a, b) => {
|
|
82
|
+
if (a[field] === b[field]) return 0
|
|
83
|
+
return a[field] > b[field] ? 1 : -1
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const defaultsDeep = (...items) => {
|
|
88
|
+
const out = {}
|
|
89
|
+
const apply = (target, src) => {
|
|
90
|
+
if (!isPlainObject(src)) return target
|
|
91
|
+
for (const k of Object.keys(src)) {
|
|
92
|
+
const tv = target[k]
|
|
93
|
+
const sv = src[k]
|
|
94
|
+
if (isPlainObject(sv)) {
|
|
95
|
+
target[k] = apply(isPlainObject(tv) ? tv : {}, sv)
|
|
96
|
+
} else if (target[k] === undefined) {
|
|
97
|
+
target[k] = sv
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return target
|
|
101
|
+
}
|
|
102
|
+
for (let i = 0; i < items.length; i++) apply(out, items[i])
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const get = (obj, p, fallback) => {
|
|
107
|
+
const parts = String(p).split('.')
|
|
108
|
+
let cur = obj
|
|
109
|
+
for (const part of parts) {
|
|
110
|
+
if (cur == null) return fallback
|
|
111
|
+
cur = cur[part]
|
|
112
|
+
}
|
|
113
|
+
return cur === undefined ? fallback : cur
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let gid = 0
|
|
117
|
+
export const generateId = () => `id-${++gid}`
|
|
118
|
+
export const isSet = (v) => v !== undefined && v !== null
|
|
119
|
+
|
|
120
|
+
export class Base {
|
|
121
|
+
constructor (pkgName, app) {
|
|
122
|
+
this.pkgName = pkgName
|
|
123
|
+
this.app = app
|
|
124
|
+
this.ns = camelCase(pkgName)
|
|
125
|
+
this.alias = this.ns
|
|
126
|
+
this.log = {
|
|
127
|
+
trace: () => {},
|
|
128
|
+
debug: () => {},
|
|
129
|
+
info: () => {},
|
|
130
|
+
warn: () => {},
|
|
131
|
+
error: () => {}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
t = (text) => text
|
|
136
|
+
|
|
137
|
+
error = (msg, ...args) => new Error(`${msg}:${args.join(',')}`)
|
|
138
|
+
|
|
139
|
+
getConfig = (pathLike) => get(this.config, pathLike)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class Tools {
|
|
143
|
+
constructor (plugin) {
|
|
144
|
+
this.plugin = plugin
|
|
145
|
+
this.app = plugin.app
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async dispose () {
|
|
149
|
+
this.plugin = null
|
|
150
|
+
this.app = null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
bindThis (...fns) {
|
|
154
|
+
for (const fn of fns) {
|
|
155
|
+
if (!fn?.name) continue
|
|
156
|
+
this[fn.name] = fn.bind(this)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const createAppStub = (root = '/tmp/dobo-test') => {
|
|
162
|
+
const app = {
|
|
163
|
+
dir: root,
|
|
164
|
+
t: (text) => text,
|
|
165
|
+
lib: {
|
|
166
|
+
_: {
|
|
167
|
+
find,
|
|
168
|
+
filter,
|
|
169
|
+
isString: (v) => typeof v === 'string',
|
|
170
|
+
isArray: Array.isArray,
|
|
171
|
+
isFunction,
|
|
172
|
+
map,
|
|
173
|
+
get,
|
|
174
|
+
pick,
|
|
175
|
+
groupBy,
|
|
176
|
+
isEmpty,
|
|
177
|
+
isNaN: Number.isNaN,
|
|
178
|
+
trim,
|
|
179
|
+
isPlainObject,
|
|
180
|
+
uniq,
|
|
181
|
+
without,
|
|
182
|
+
defaults,
|
|
183
|
+
findIndex,
|
|
184
|
+
chunk,
|
|
185
|
+
last,
|
|
186
|
+
forOwn,
|
|
187
|
+
kebabCase,
|
|
188
|
+
keys,
|
|
189
|
+
has,
|
|
190
|
+
camelCase,
|
|
191
|
+
isNumber: (v) => typeof v === 'number' && !Number.isNaN(v),
|
|
192
|
+
omit,
|
|
193
|
+
cloneDeep,
|
|
194
|
+
pullAt,
|
|
195
|
+
orderBy
|
|
196
|
+
},
|
|
197
|
+
aneka: {
|
|
198
|
+
pascalCase,
|
|
199
|
+
camelCase,
|
|
200
|
+
defaultsDeep,
|
|
201
|
+
generateId,
|
|
202
|
+
isSet
|
|
203
|
+
},
|
|
204
|
+
dayjs: (value) => {
|
|
205
|
+
const d = value instanceof Date ? value : new Date(value)
|
|
206
|
+
return {
|
|
207
|
+
isValid: () => !Number.isNaN(d.getTime()),
|
|
208
|
+
toDate: () => new Date(d.getTime()),
|
|
209
|
+
format: (pattern) => {
|
|
210
|
+
const y = d.getUTCFullYear()
|
|
211
|
+
const m = String(d.getUTCMonth() + 1).padStart(2, '0')
|
|
212
|
+
const day = String(d.getUTCDate()).padStart(2, '0')
|
|
213
|
+
if (pattern === 'YYYY-MM-DD') return `${y}-${m}-${day}`
|
|
214
|
+
if (pattern === 'YYYY-MM') return `${y}-${m}`
|
|
215
|
+
if (pattern === 'YYYY') return `${y}`
|
|
216
|
+
return d.toISOString()
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
fs: {
|
|
221
|
+
ensureDirSync: () => {}
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
baseClass: {
|
|
225
|
+
Base,
|
|
226
|
+
Tools
|
|
227
|
+
},
|
|
228
|
+
bajo: {
|
|
229
|
+
breakNsPath: (v) => {
|
|
230
|
+
const i = v.indexOf(':')
|
|
231
|
+
return i === -1 ? { ns: v, path: '' } : { ns: v.slice(0, i), path: v.slice(i + 1) }
|
|
232
|
+
},
|
|
233
|
+
join: (arr = []) => arr.join(', '),
|
|
234
|
+
runHook: async () => {},
|
|
235
|
+
eachPlugins: async () => {},
|
|
236
|
+
buildCollections: async () => [],
|
|
237
|
+
importModule: async () => {},
|
|
238
|
+
callHandler: async (scope, handler, ...args) => typeof handler === 'function' ? handler.call(scope, ...args) : undefined,
|
|
239
|
+
readConfig: async () => ({})
|
|
240
|
+
},
|
|
241
|
+
getPluginDataDir: (name) => path.join(root, 'plugins', name)
|
|
242
|
+
}
|
|
243
|
+
return app
|
|
244
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/* global describe, it, beforeEach */
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai'
|
|
4
|
+
import actionFactory from '../../lib/factory/action.js'
|
|
5
|
+
import { createAppStub } from './_stub.js'
|
|
6
|
+
|
|
7
|
+
describe('dobo action class (unit)', () => {
|
|
8
|
+
let app
|
|
9
|
+
let plugin
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
app = createAppStub('/tmp/dobo-action')
|
|
13
|
+
plugin = { app }
|
|
14
|
+
await actionFactory.call(plugin)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('creates chainable action class and runs model handlers', async () => {
|
|
18
|
+
const calls = []
|
|
19
|
+
const model = {
|
|
20
|
+
plugin,
|
|
21
|
+
getRecord: async (...args) => {
|
|
22
|
+
calls.push(args)
|
|
23
|
+
return { ok: true }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const action = new app.baseClass.DoboAction(model, 'getRecord', 'abc')
|
|
28
|
+
const resp = await action
|
|
29
|
+
.noHook()
|
|
30
|
+
.dataOnly(false)
|
|
31
|
+
.run({ noCache: false })
|
|
32
|
+
|
|
33
|
+
expect(resp.ok).to.equal(true)
|
|
34
|
+
expect(calls).to.have.length(1)
|
|
35
|
+
expect(calls[0][0]).to.equal('abc')
|
|
36
|
+
expect(calls[0][1]).to.be.an('object')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('builder methods for create/update payload set expected positional args', async () => {
|
|
40
|
+
const model = {
|
|
41
|
+
plugin,
|
|
42
|
+
updateRecord: async (id, body, options) => ({ id, body, options })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const action = new app.baseClass.DoboAction(model)
|
|
46
|
+
const result = await action.updateRecord('id-1', { name: 'A' }).run()
|
|
47
|
+
expect(result.id).to.equal('id-1')
|
|
48
|
+
expect(result.body.name).to.equal('A')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('dispose clears action references', async () => {
|
|
52
|
+
const model = { plugin, getRecord: async () => ({}) }
|
|
53
|
+
const action = new app.baseClass.DoboAction(model, 'getRecord', 'x')
|
|
54
|
+
await action.dispose()
|
|
55
|
+
expect(action.model).to.equal(null)
|
|
56
|
+
expect(action._options).to.equal(null)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/* global describe, it, beforeEach */
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai'
|
|
4
|
+
import connectionFactory from '../../lib/factory/connection.js'
|
|
5
|
+
import { createAppStub } from './_stub.js'
|
|
6
|
+
|
|
7
|
+
describe('dobo connection class (unit)', () => {
|
|
8
|
+
let app
|
|
9
|
+
let plugin
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
app = createAppStub('/tmp/dobo-connection')
|
|
13
|
+
app.baseClass.DoboNullAdapter = class DoboNullAdapter {}
|
|
14
|
+
app.baseClass.DoboAdapter = class DoboAdapter {}
|
|
15
|
+
plugin = {
|
|
16
|
+
app,
|
|
17
|
+
getAdapter: () => ({ sanitizeConnection: async () => {}, connect: async () => ({ client: true }) })
|
|
18
|
+
}
|
|
19
|
+
await connectionFactory.call(plugin)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('creates DoboConnection, initializes adapter and connects', async () => {
|
|
23
|
+
const conn = new app.baseClass.DoboConnection(plugin, { name: 'default', adapter: 'dobo:memory', models: ['User'] })
|
|
24
|
+
await conn.initAdapter('dobo:memory')
|
|
25
|
+
expect(conn.adapter).to.be.an('object')
|
|
26
|
+
await conn.connect(true)
|
|
27
|
+
expect(conn.connected).to.equal(true)
|
|
28
|
+
expect(conn.client).to.deep.equal({ client: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('dispose clears adapter reference', async () => {
|
|
32
|
+
const conn = new app.baseClass.DoboConnection(plugin, { name: 'default', adapter: 'dobo:memory' })
|
|
33
|
+
conn.adapter = { ok: true }
|
|
34
|
+
await conn.dispose()
|
|
35
|
+
expect(conn.adapter).to.equal(null)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/* global describe, it, beforeEach */
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai'
|
|
4
|
+
import factory from '../../index.js'
|
|
5
|
+
import { createAppStub } from './_stub.js'
|
|
6
|
+
|
|
7
|
+
describe('dobo core class (unit)', () => {
|
|
8
|
+
let app
|
|
9
|
+
let Dobo
|
|
10
|
+
let dobo
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
app = createAppStub('/tmp/dobo-unit')
|
|
14
|
+
Dobo = await factory.call({ app }, 'dobo')
|
|
15
|
+
dobo = new Dobo()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('creates plugin class with expected static values and defaults', () => {
|
|
19
|
+
expect(Dobo.aggregateTypes).to.include('count')
|
|
20
|
+
expect(Dobo.histogramTypes).to.include('yearly')
|
|
21
|
+
expect(Dobo.idTypes).to.include('string')
|
|
22
|
+
expect(dobo.config.default.filter.limit).to.equal(25)
|
|
23
|
+
expect(dobo.adapters).to.deep.equal([])
|
|
24
|
+
expect(dobo.connections).to.deep.equal([])
|
|
25
|
+
expect(dobo.features).to.deep.equal([])
|
|
26
|
+
expect(dobo.models).to.deep.equal([])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('property key helpers include type-specific fields', () => {
|
|
30
|
+
expect(dobo.getPropertyKeysByType('string')).to.include('maxLength')
|
|
31
|
+
expect(dobo.getPropertyKeysByType('text')).to.include('textType')
|
|
32
|
+
expect(dobo.getPropertyKeysByType('smallint')).to.include('autoInc')
|
|
33
|
+
|
|
34
|
+
const keys = dobo.getAllPropertyKeys({ constructor: { propertyKeys: ['xFlag'] } })
|
|
35
|
+
expect(keys).to.include('name')
|
|
36
|
+
expect(keys).to.include('validator')
|
|
37
|
+
expect(keys).to.include('xFlag')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('connection, adapter, feature and model lookups work', () => {
|
|
41
|
+
dobo.connections = [{ name: 'default' }, { name: 'report' }]
|
|
42
|
+
expect(dobo.getConnection('report').name).to.equal('report')
|
|
43
|
+
expect(dobo.getConnection('none', true)).to.equal(undefined)
|
|
44
|
+
|
|
45
|
+
dobo.adapters = [
|
|
46
|
+
{ name: 'memory', plugin: { ns: 'dobo' } },
|
|
47
|
+
{ name: 'proxy', plugin: { ns: 'ext' } }
|
|
48
|
+
]
|
|
49
|
+
expect(dobo.getAdapter('memory').name).to.equal('memory')
|
|
50
|
+
expect(dobo.getAdapter('ext:proxy').name).to.equal('proxy')
|
|
51
|
+
expect(dobo.getAdapter('missing', true)).to.equal(undefined)
|
|
52
|
+
|
|
53
|
+
dobo.features = [{ name: 'createdAt', plugin: { ns: 'dobo' } }]
|
|
54
|
+
expect(dobo.getFeature('createdAt').name).to.equal('createdAt')
|
|
55
|
+
|
|
56
|
+
dobo.models = [{ name: 'Order' }, { name: 'Customer' }]
|
|
57
|
+
expect(dobo.getModel('Order').name).to.equal('Order')
|
|
58
|
+
expect(dobo.getModel('order').name).to.equal('Order')
|
|
59
|
+
expect(dobo.getModel('none', true)).to.equal(undefined)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('sanitize helpers and aggregate calculators work', () => {
|
|
63
|
+
expect(dobo.sanitizeBoolean('true')).to.equal(true)
|
|
64
|
+
expect(dobo.sanitizeBoolean(false)).to.equal(false)
|
|
65
|
+
expect(dobo.sanitizeFloat('12.4')).to.equal(12.4)
|
|
66
|
+
expect(dobo.sanitizeInt('12')).to.equal(12)
|
|
67
|
+
expect(dobo.sanitizeString(44)).to.equal('44')
|
|
68
|
+
expect(dobo.sanitizeObject('{"a":1}')).to.deep.equal({ a: 1 })
|
|
69
|
+
|
|
70
|
+
const rows = [
|
|
71
|
+
{ grp: 'a', price: 10 },
|
|
72
|
+
{ grp: 'a', price: 30 },
|
|
73
|
+
{ grp: 'b', price: 20 }
|
|
74
|
+
]
|
|
75
|
+
const agg = dobo.calcAggregate({ data: rows, group: 'grp', field: 'price', aggregates: ['count', 'avg', 'min', 'max'] })
|
|
76
|
+
expect(agg).to.have.length(2)
|
|
77
|
+
const itemA = agg.find(i => i.grp === 'a')
|
|
78
|
+
expect(itemA.count).to.equal(2)
|
|
79
|
+
expect(itemA.max).to.equal(30)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('histogram and query parsers work with valid input', () => {
|
|
83
|
+
const rows = [
|
|
84
|
+
{ createdAt: '2026-01-01T00:00:00Z', score: 5 },
|
|
85
|
+
{ createdAt: '2026-01-01T12:00:00Z', score: 7 },
|
|
86
|
+
{ createdAt: '2026-01-02T00:00:00Z', score: 10 }
|
|
87
|
+
]
|
|
88
|
+
const hist = dobo.calcHistogram({ data: rows, type: 'daily', group: 'createdAt', field: 'score', aggregates: ['count'] })
|
|
89
|
+
expect(hist).to.have.length(2)
|
|
90
|
+
expect(hist[0]).to.have.property('date')
|
|
91
|
+
|
|
92
|
+
const model = {
|
|
93
|
+
scanables: ['name'],
|
|
94
|
+
sortables: ['name'],
|
|
95
|
+
properties: [{ name: 'name', type: 'string' }],
|
|
96
|
+
adapter: { idField: { name: '_id' } }
|
|
97
|
+
}
|
|
98
|
+
const parsed = dobo.parseQuery('name:~\'john\'', model)
|
|
99
|
+
expect(parsed).to.be.an('object')
|
|
100
|
+
|
|
101
|
+
const parsedAny = dobo.parseQuery('john*', model)
|
|
102
|
+
expect(parsedAny).to.be.an('object')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('regex json helpers and model hooks work', async () => {
|
|
106
|
+
const input = { n: /abc/gi }
|
|
107
|
+
const text = dobo.replaceRegexInJson(input, true)
|
|
108
|
+
const revived = dobo.reviveRegexInJson(text, true)
|
|
109
|
+
expect(revived.n).to.be.instanceOf(RegExp)
|
|
110
|
+
expect(revived.n.flags).to.include('g')
|
|
111
|
+
|
|
112
|
+
const calls = []
|
|
113
|
+
const model = {
|
|
114
|
+
hooks: [
|
|
115
|
+
{ name: 'beforeCreateRecord', level: 2, handler: async () => calls.push(2) },
|
|
116
|
+
{ name: 'beforeCreateRecord', level: 1, handler: async () => calls.push(1) }
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
await dobo.runModelHook(model, 'beforeCreateRecord', {})
|
|
120
|
+
expect(calls).to.deep.equal([1, 2])
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('default values and hard-cap last page handler work', () => {
|
|
124
|
+
dobo.config.default.filter = { limit: 10, maxLimit: 50, maxPage: 30, sort: [] }
|
|
125
|
+
dobo.config.default.hardCap = 100
|
|
126
|
+
dobo.config.default.warnings = true
|
|
127
|
+
|
|
128
|
+
const def = dobo.getDefaultValues()
|
|
129
|
+
expect(def.limit).to.equal(10)
|
|
130
|
+
expect(def.hardCap).to.equal(100)
|
|
131
|
+
|
|
132
|
+
const result = dobo.handleLastPage({ count: 300, limit: 10, page: 25 })
|
|
133
|
+
expect(result.count).to.equal(100)
|
|
134
|
+
expect(result.data).to.deep.equal([])
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/* global describe, it, beforeEach */
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai'
|
|
4
|
+
import featureFactory from '../../lib/factory/feature.js'
|
|
5
|
+
import { createAppStub } from './_stub.js'
|
|
6
|
+
|
|
7
|
+
describe('dobo feature class (unit)', () => {
|
|
8
|
+
let app
|
|
9
|
+
let plugin
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
app = createAppStub('/tmp/dobo-feature')
|
|
13
|
+
plugin = { app }
|
|
14
|
+
await featureFactory.call(plugin)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('creates DoboFeature and disposes cleanly', async () => {
|
|
18
|
+
const feat = new app.baseClass.DoboFeature(plugin, { name: 'createdAt', handler: async () => ({}) })
|
|
19
|
+
expect(feat.name).to.equal('createdAt')
|
|
20
|
+
expect(feat.handler).to.be.a('function')
|
|
21
|
+
await feat.dispose()
|
|
22
|
+
expect(feat.name).to.equal(null)
|
|
23
|
+
expect(feat.handler).to.equal(null)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/* global describe, it, beforeEach */
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai'
|
|
4
|
+
import modelFactory from '../../lib/factory/model.js'
|
|
5
|
+
import { createAppStub, Base } from './_stub.js'
|
|
6
|
+
|
|
7
|
+
describe('dobo model class (unit)', () => {
|
|
8
|
+
let app
|
|
9
|
+
let plugin
|
|
10
|
+
let model
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
app = createAppStub('/tmp/dobo-model-unit')
|
|
14
|
+
app.dobo = {
|
|
15
|
+
models: [],
|
|
16
|
+
sanitizeDate: (v) => v,
|
|
17
|
+
sanitizeByType: (v) => v,
|
|
18
|
+
checkAggregateParams: () => {},
|
|
19
|
+
checkHistogramParams: () => {},
|
|
20
|
+
runModelHook: async () => {},
|
|
21
|
+
parseQuery: (q) => q
|
|
22
|
+
}
|
|
23
|
+
app.baseClass.DoboAction = class DoboAction {
|
|
24
|
+
constructor (m, name, ...args) {
|
|
25
|
+
this.model = m
|
|
26
|
+
this.name = name
|
|
27
|
+
this.args = args
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
plugin = new Base('dobo', app)
|
|
32
|
+
await modelFactory.call(plugin)
|
|
33
|
+
|
|
34
|
+
const options = {
|
|
35
|
+
name: 'Order',
|
|
36
|
+
baseName: 'order',
|
|
37
|
+
connection: {
|
|
38
|
+
name: 'default',
|
|
39
|
+
adapter: { idField: { name: '_id', type: 'string', maxLength: 50 } }
|
|
40
|
+
},
|
|
41
|
+
properties: [
|
|
42
|
+
{ name: 'id', type: 'string', maxLength: 50 },
|
|
43
|
+
{ name: 'title', type: 'string', maxLength: 50 },
|
|
44
|
+
{ name: 'total', type: 'double' },
|
|
45
|
+
{ name: 'virtualTitle', type: 'string', virtual: true }
|
|
46
|
+
],
|
|
47
|
+
indexes: [{ name: 'idx_title', type: 'index', fields: ['title'] }],
|
|
48
|
+
hooks: []
|
|
49
|
+
}
|
|
50
|
+
model = new app.baseClass.DoboModel(plugin, options)
|
|
51
|
+
app.dobo.models.push(model)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('builds aliases, action and property getters correctly', () => {
|
|
55
|
+
const act = model.action('getRecord', 'id-1')
|
|
56
|
+
expect(act.name).to.equal('getRecord')
|
|
57
|
+
expect(model.getProperty('title').type).to.equal('string')
|
|
58
|
+
expect(model.getField('title').name).to.equal('title')
|
|
59
|
+
expect(model.hasProperty('title')).to.equal(true)
|
|
60
|
+
expect(model.hasField('ghost')).to.equal(false)
|
|
61
|
+
expect(model.getIndexes()).to.have.length(1)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('returns filtered property lists (all, virtual, non-virtual)', () => {
|
|
65
|
+
expect(model.getProperties().length).to.equal(4)
|
|
66
|
+
expect(model.getProperties({ noVirtual: true }).length).to.equal(3)
|
|
67
|
+
expect(model.getProperties({ noVirtual: true, namesOnly: true })).to.include('title')
|
|
68
|
+
expect(model.getVirtualProperties(true)).to.deep.equal(['virtualTitle'])
|
|
69
|
+
expect(model.getNonVirtualProperties(true)).to.include('id')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('sanitizes and syncs id field', () => {
|
|
73
|
+
model.properties[0].type = 'integer'
|
|
74
|
+
expect(model.sanitizeId('12')).to.equal(12)
|
|
75
|
+
|
|
76
|
+
model.syncIdField({ name: 'id', type: 'string', maxLength: 20 })
|
|
77
|
+
expect(model.properties[0].maxLength).to.equal(20)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('buildPropValues supports array and req translation hooks', async () => {
|
|
81
|
+
const prop = {
|
|
82
|
+
name: 'status',
|
|
83
|
+
values: ['draft', { value: 'done', text: 'done' }]
|
|
84
|
+
}
|
|
85
|
+
const req = {
|
|
86
|
+
te: (k) => k === 'statusDone',
|
|
87
|
+
t: () => 'Selesai'
|
|
88
|
+
}
|
|
89
|
+
const vals = await model.buildPropValues(prop, { req })
|
|
90
|
+
expect(vals).to.have.length(2)
|
|
91
|
+
expect(vals[1].text).to.equal('Selesai')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('_simpleLookup handles string/object/array definitions', async () => {
|
|
95
|
+
const userModel = {
|
|
96
|
+
findOneRecord: async () => ({ id: 'u1', username: 'john' })
|
|
97
|
+
}
|
|
98
|
+
app.dobo.getModel = () => userModel
|
|
99
|
+
|
|
100
|
+
const a = await model._simpleLookup('User:username:id=u1', {})
|
|
101
|
+
expect(a).to.equal('john')
|
|
102
|
+
|
|
103
|
+
const b = await model._simpleLookup({ model: 'User', field: 'id', query: 'id={id}' }, { id: 'u9' })
|
|
104
|
+
expect(b).to.equal('u1')
|
|
105
|
+
|
|
106
|
+
const c = await model._simpleLookup(['User', 'id', 'id=u5'], {})
|
|
107
|
+
expect(c).to.equal('u1')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('dispose clears connection and adapter references', async () => {
|
|
111
|
+
await model.dispose()
|
|
112
|
+
expect(model.connection).to.equal(null)
|
|
113
|
+
expect(model.adapter).to.equal(null)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/* global describe, it, beforeEach */
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai'
|
|
4
|
+
import factory from '../../index.js'
|
|
5
|
+
import { sanitizeAll } from '../../lib/helper.js'
|
|
6
|
+
import { createAppStub } from './_stub.js'
|
|
7
|
+
|
|
8
|
+
describe('helper sanitizeAll (unit)', () => {
|
|
9
|
+
let app
|
|
10
|
+
let dobo
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
app = createAppStub('/tmp/dobo-helper-all')
|
|
14
|
+
const Dobo = await factory.call({ app }, 'dobo')
|
|
15
|
+
dobo = new Dobo()
|
|
16
|
+
app.dobo = dobo
|
|
17
|
+
dobo.fatal = (msg, ...args) => { throw new Error(`${msg}:${args.join(',')}`) }
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('normalizes properties, indexes, sortables and hidden fields', async () => {
|
|
21
|
+
const schema = {
|
|
22
|
+
name: 'Order',
|
|
23
|
+
properties: [
|
|
24
|
+
{ name: 'id', type: 'string', index: 'primary' },
|
|
25
|
+
{ name: 'name', type: 'string', minLength: '0', maxLength: '20' },
|
|
26
|
+
{ name: 'amount', type: 'double' }
|
|
27
|
+
],
|
|
28
|
+
indexes: [{ name: 'idx_name', type: 'index', fields: ['name'] }],
|
|
29
|
+
hidden: ['name', 'ghost']
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
await sanitizeAll.call(dobo, schema)
|
|
33
|
+
|
|
34
|
+
expect(schema.sortables).to.include('name')
|
|
35
|
+
expect(schema.hidden).to.deep.equal(['name'])
|
|
36
|
+
const nameProp = schema.properties.find(p => p.name === 'name')
|
|
37
|
+
expect(nameProp.maxLength).to.equal(20)
|
|
38
|
+
expect(nameProp.minLength).to.equal(undefined)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/* global describe, it, beforeEach */
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai'
|
|
4
|
+
import factory from '../../index.js'
|
|
5
|
+
import { sanitizeRef } from '../../lib/helper.js'
|
|
6
|
+
import { createAppStub } from './_stub.js'
|
|
7
|
+
|
|
8
|
+
describe('helper sanitizeRef (unit)', () => {
|
|
9
|
+
let app
|
|
10
|
+
let dobo
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
app = createAppStub('/tmp/dobo-helper-ref')
|
|
14
|
+
const Dobo = await factory.call({ app }, 'dobo')
|
|
15
|
+
dobo = new Dobo()
|
|
16
|
+
app.dobo = dobo
|
|
17
|
+
dobo.fatal = (msg, ...args) => { throw new Error(`${msg}:${args.join(',')}`) }
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('normalizes reference fields and filters unknown refs', async () => {
|
|
21
|
+
const user = {
|
|
22
|
+
name: 'User',
|
|
23
|
+
properties: [{ name: 'id' }, { name: 'name' }]
|
|
24
|
+
}
|
|
25
|
+
const order = {
|
|
26
|
+
name: 'Order',
|
|
27
|
+
properties: [
|
|
28
|
+
{
|
|
29
|
+
name: 'userId',
|
|
30
|
+
ref: {
|
|
31
|
+
user: { model: 'User', field: 'id', fields: ['name'] },
|
|
32
|
+
missing: { model: 'Unknown', field: 'id' }
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await sanitizeRef.call(dobo, order, [user, order])
|
|
39
|
+
|
|
40
|
+
expect(order.properties[0].ref.user.type).to.equal('1:1')
|
|
41
|
+
expect(order.properties[0].ref.user.searchField).to.equal('id')
|
|
42
|
+
expect(order.properties[0].ref.user.labelField).to.equal('id')
|
|
43
|
+
expect(order.properties[0].ref.user.fields).to.include('id')
|
|
44
|
+
expect(order.properties[0].ref.missing).to.equal(undefined)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('throws when duplicate ref keys exist across properties', async () => {
|
|
48
|
+
const schema = {
|
|
49
|
+
name: 'Order',
|
|
50
|
+
properties: [
|
|
51
|
+
{ name: 'a', ref: { rel: { model: 'User' } } },
|
|
52
|
+
{ name: 'b', ref: { rel: { model: 'User' } } }
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
const user = { name: 'User', properties: [{ name: 'id' }] }
|
|
56
|
+
|
|
57
|
+
let err
|
|
58
|
+
try {
|
|
59
|
+
await sanitizeRef.call(dobo, schema, [schema, user])
|
|
60
|
+
} catch (e) { err = e }
|
|
61
|
+
expect(err).to.be.instanceOf(Error)
|
|
62
|
+
expect(err.message).to.include('duplicateRefKeys')
|
|
63
|
+
})
|
|
64
|
+
})
|
package/wiki/CHANGES.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changes
|
|
2
2
|
|
|
3
|
+
## 2026-07-31
|
|
4
|
+
|
|
5
|
+
- [2.36.0] Add test suite
|
|
6
|
+
- [2.36.0] Add parameter `options` to `model.sanitizeRecord()`
|
|
7
|
+
|
|
8
|
+
## 2026-07-29
|
|
9
|
+
|
|
10
|
+
- [2.35.0] Add parameter `options` to `adapter.sanitizeRecord()` to allow passing additional options for sanitization
|
|
11
|
+
- [2.35.0] Change `options.i18n` to `options.intl` in `adapter.sanitizeRecord()` for consistency with other methods
|
|
12
|
+
|
|
3
13
|
## 2026-07-28
|
|
4
14
|
|
|
5
15
|
- [2.34.1] Bug fix in `adapter.sanitizeRecord()`
|