dobo 2.35.0 → 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/model/sanitize-record.js +13 -11
- 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 +5 -0
|
@@ -43,6 +43,14 @@ async function sanitizeRecord (record = {}, opts = {}) {
|
|
|
43
43
|
if (opts.fmt) {
|
|
44
44
|
newRecord._fmt = cloneDeep(newRecord)
|
|
45
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
|
+
|
|
46
54
|
for (const key in newRecord) {
|
|
47
55
|
const prop = this.getProperty(key)
|
|
48
56
|
if (!prop) continue
|
|
@@ -56,19 +64,13 @@ async function sanitizeRecord (record = {}, opts = {}) {
|
|
|
56
64
|
else if (isFunction(prop.format)) newRecord._fmt[key] = await prop.format.call(this, value, newRecord, opts)
|
|
57
65
|
else if (isString(prop.format)) newRecord._fmt[key] = await callHandler(this.plugin, this, value, newRecord, opts)
|
|
58
66
|
else {
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
date: get(opts, 'intl.date', get(opts, 'req.site.setting.bajo.intl.date')),
|
|
63
|
-
time: get(opts, 'intl.time', get(opts, 'req.site.setting.bajo.intl.time')),
|
|
64
|
-
timeZone: get(opts, 'intl.timeZone', get(opts, 'req.site.setting.bajo.intl.timeZone')),
|
|
65
|
-
emptyValue: get(opts, 'intl.emptyValue', get(opts, 'req.site.setting.bajo.intl.emptyValue')),
|
|
66
|
-
unitSys: get(opts, 'intl.unitSys', get(opts, 'req.site.setting.bajo.intl.unitSys')),
|
|
67
|
+
const _options = {
|
|
68
|
+
...options,
|
|
69
|
+
field: key,
|
|
67
70
|
latitude: ['lat', 'latitude'].includes(key),
|
|
68
|
-
longitude: ['lon', 'lng', 'longitude'].includes(key)
|
|
69
|
-
field: key
|
|
71
|
+
longitude: ['lon', 'lng', 'longitude'].includes(key)
|
|
70
72
|
}
|
|
71
|
-
newRecord._fmt[key] = format(value, prop.type,
|
|
73
|
+
newRecord._fmt[key] = format(value, prop.type, _options)
|
|
72
74
|
}
|
|
73
75
|
}
|
|
74
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,10 @@
|
|
|
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
|
+
|
|
3
8
|
## 2026-07-29
|
|
4
9
|
|
|
5
10
|
- [2.35.0] Add parameter `options` to `adapter.sanitizeRecord()` to allow passing additional options for sanitization
|