w-orm-reladb 1.1.8 → 1.1.10

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.
@@ -1,12 +1,15 @@
1
1
  import assert from 'assert'
2
2
  import Sequelize from 'sequelize'
3
- import cvFind, { getColKind, normPk } from '../src/cvFind.mjs'
3
+ import cvFind, { getColKind, normPk, chkFindShape } from '../src/cvFind.mjs'
4
+ import cvOrmFind from 'wsemi/src/cvOrmFind.mjs'
5
+ import cvCases from 'wsemi/src/_cvOrmFindCases.mjs'
4
6
 
5
7
 
6
8
  //cvFind為select與delAll共用之條件轉譯純函數, 本檔不連線資料庫
7
9
  //斷言分兩類: 拒收(reject之訊息)與方言專屬之SQL片段(ESCAPE、N前綴、LOWER內之實際欄名);
8
10
  //命中集合之語意由test/api-suite.mjs之find段於sqlite與mssql實際執行斷言, 本檔不以SQL之括號結構作斷言
9
- //對應CLAUDE_rulebook.md之R1–R6與R8(normPk)
11
+ //第33項批1(R11): 形狀層由wsemi之cvOrmFind單一擁有, 本檔之舊案中屬形狀層者改以其訊息斷言reject, 被移除之功能($not $nor $regex $exists、RegExp、Symbol Op、陣列值等)改為reject案;
12
+ //對應CLAUDE_rulebook.md之R1–R6與R8(normPk);「審計02」組為R3欄位引用不變式(斷言SQL含欄名及引用次數, 非括號結構)、R4欄名解析、R4 ctx.dateNoTz
10
13
 
11
14
 
12
15
  let Op = Sequelize.Op
@@ -29,11 +32,11 @@ let defs = {
29
32
  }
30
33
 
31
34
  //離線之sequelize實例, 建構時不連線
32
- let mk = (dialect) => {
35
+ let mk = (dialect, dfs = defs) => {
33
36
  let sq = dialect === 'sqlite'
34
37
  ? new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
35
38
  : new Sequelize('worm', 'u', 'p', { dialect: 'mssql', host: 'localhost', port: 1, logging: false })
36
- let md = sq.define('tcond', defs, { tableName: 'tcond', timestamps: false })
39
+ let md = sq.define('tcond', dfs, { tableName: 'tcond', timestamps: false })
37
40
  let qg = sq.getQueryInterface().queryGenerator
38
41
  let ctx = { escape: (v) => sq.escape(v), attrs: md.rawAttributes, dialect }
39
42
  let where = (find, ex = {}) => {
@@ -41,7 +44,22 @@ let mk = (dialect) => {
41
44
  let k = sql.indexOf(' WHERE ')
42
45
  return k >= 0 ? sql.slice(k + 7).replace(/;$/, '') : ''
43
46
  }
44
- return { ctx, where }
47
+ //whereDb, 同where但先經sequelize之mapWhereFieldNames(findAll/destroy實際會做)把屬性名映射為資料庫欄名, 供斷言實際送出之欄名
48
+ let whereDb = (find, ex = {}) => {
49
+ let wh = Sequelize.Utils.mapWhereFieldNames(cvFind(find, { ...ctx, ...ex }), md)
50
+ let sql = qg.selectQuery('tcond', { where: wh }, md)
51
+ let k = sql.indexOf(' WHERE ')
52
+ return k >= 0 ? sql.slice(k + 7).replace(/;$/, '') : ''
53
+ }
54
+ //where0, 同where但不給attrs(欄位型別any、鍵不解析而交資料庫, R4), 供斷言「未給attrs」路徑之SQL(如欄位引用不變式R3)
55
+ let where0 = (find, ex = {}) => {
56
+ let sql = qg.selectQuery('tcond', { where: cvFind(find, { escape: ctx.escape, dialect, ...ex }) }, md)
57
+ let k = sql.indexOf(' WHERE ')
58
+ return k >= 0 ? sql.slice(k + 7).replace(/;$/, '') : ''
59
+ }
60
+ //q, 依方言引用識別字(sqlite為反引號, mssql為方括號), 供斷言SQL文字含某欄名
61
+ let q = (name) => (dialect === 'sqlite' ? `\`${name}\`` : `[${name}]`)
62
+ return { ctx, where, where0, whereDb, q, qg }
45
63
  }
46
64
  let LT = mk('sqlite')
47
65
  let MS = mk('mssql')
@@ -58,58 +76,79 @@ let throwsMsg = (fn, msg) => {
58
76
  assert.ok(String(e.message).indexOf(msg) >= 0, `訊息不含[${msg}]: ${e.message}`)
59
77
  }
60
78
 
79
+ //withTz, 暫時切換主機時區(執行期設定process.env.TZ, Node會重設時區快取), 結束必還原
80
+ //還原須以賦值而非delete: Windows上delete process.env.TZ不會還原主機時區(實測仍停在最後設定之時區), 故載入時先記下主機時區名稱
81
+ let hostTz = Intl.DateTimeFormat().resolvedOptions().timeZone
82
+ let withTz = (tz, fn) => {
83
+ let old = process.env.TZ
84
+ process.env.TZ = tz
85
+ try {
86
+ return fn()
87
+ }
88
+ finally {
89
+ if (old !== undefined) {
90
+ process.env.TZ = old
91
+ }
92
+ else if (process.platform === 'win32') {
93
+ process.env.TZ = hostTz
94
+ }
95
+ else {
96
+ delete process.env.TZ
97
+ }
98
+ }
99
+ }
100
+
61
101
 
62
102
  describe('cvFind', function() {
63
103
 
64
- it('R1 非物件與陣列之find回空物件(現況保留), 非一般物件拒收', function() {
65
- for (let v of [undefined, null, [], 'a', 1, true, [{ id: 'a' }]]) {
104
+ it('R1 頂層: 未給、undefined、{}、null原型空物件為全部; 非條件物件(null、陣列、純量、非一般物件等)一律reject', function() {
105
+ for (let v of [undefined, {}, Object.create(null)]) {
66
106
  assert.deepStrictEqual(cvFind(v, LT.ctx), {})
67
107
  }
68
- assert.deepStrictEqual(cvFind({}, LT.ctx), {})
69
108
  class Q {
70
109
  constructor() {
71
110
  this.s = 'a'
72
111
  }
73
112
  }
74
- for (let v of [Object.create({ s: 'a' }), new Q(), new Date(), new Map([['s', 'a']]), /a/]) {
75
- throwsMsg(() => cvFind(v, LT.ctx), 'find must be a plain object')
113
+ for (let v of [null, [], [{ id: 'a' }], 'a', 1, true, Object.create({ s: 'a' }), new Q(), new Date(), new Map([['s', 'a']]), /a/]) {
114
+ throwsMsg(() => cvFind(v, LT.ctx), 'invalid find: condition must be a plain object')
76
115
  }
77
116
  assert.ok(LT.where(Object.assign(Object.create(null), { s: 'a' })).indexOf('`s` = \'a\'') >= 0)
78
117
  })
79
118
 
80
- it('R2 欄名length不得使其他條件失效, 欄名含$視為欄名', function() {
119
+ it('R2 欄名length不得使其他條件失效, 欄名含$不屬欄名文法而reject', function() {
81
120
  let w = LT.where({ s: 'x', length: 0 })
82
121
  assert.ok(w.indexOf('`s` = \'x\'') >= 0 && w.indexOf('`length` = 0') >= 0, w)
83
- assert.ok(LT.where({ 'p$x': 'q' }).indexOf('`p$x` = \'q\'') >= 0)
84
- })
85
-
86
- it('R2 Symbol須為sequelize之運算子, 欄位層之邏輯Symbol拒收', function() {
87
- throwsMsg(() => cvFind({ i: { [Op.gt]: { x: 1 } } }, LT.ctx), 'value must be')
88
- throwsMsg(() => cvFind({ [Op.like]: 'x' }, LT.ctx), 'unsupported symbol operator')
89
- throwsMsg(() => cvFind({ [Symbol('or')]: [{ s: 'a' }] }, LT.ctx), 'unsupported symbol operator')
90
- throwsMsg(() => cvFind({ i: { [Symbol('gt')]: 1 } }, LT.ctx), 'unsupported symbol operator')
91
- throwsMsg(() => cvFind({ i: { [Op.or]: [{ [Op.eq]: 1 }], [Op.gt]: 10 } }, LT.ctx), 'unsupported symbol operator')
92
- throwsMsg(() => cvFind({ i: { [Op.or]: [] } }, LT.ctx), 'unsupported symbol operator')
93
- throwsMsg(() => cvFind({ i: { [Op.and]: 0 } }, LT.ctx), 'unsupported symbol operator')
94
- throwsMsg(() => cvFind({ s: 'a', [Symbol.toStringTag]: 'Q' }, LT.ctx), 'unsupported symbol operator')
95
- //Symbol與對應之$運算子產生相同之條件
96
- assert.strictEqual(LT.where({ i: { [Op.not]: { [Op.gt]: 2 } } }), LT.where({ i: { $not: { $gt: 2 } } }))
97
- assert.strictEqual(LT.where({ i: { [Op.in]: [1, 2] } }), LT.where({ i: { $in: [1, 2] } }))
98
- })
99
-
100
- it('R2 同義之鍵各自成一子式, 非列舉之鍵亦納入', function() {
101
- let w = LT.where({ i: { $gt: 5, [Op.gt]: 1 } })
102
- assert.ok(w.indexOf('`i` > 5') >= 0 && w.indexOf('`i` > 1') >= 0, w)
103
- let w2 = LT.where({ $and: [{ s: 'a' }], [Op.and]: [{ s: 'b' }] })
104
- assert.ok(w2.indexOf('`s` = \'a\'') >= 0 && w2.indexOf('`s` = \'b\'') >= 0, w2)
122
+ throwsMsg(() => cvFind({ 'p$x': 'q' }, LT.ctx), 'unsupported column name [p$x]')
123
+ })
124
+
125
+ it('R2 Symbol鍵一律reject(sequelize之Op全集、非Op之Symbol、toStringTag皆然): 原Op支援已移除', function() {
126
+ for (let f of [{ i: { [Op.gt]: { x: 1 } } }, { [Op.like]: 'x' }, { [Symbol('or')]: [{ s: 'a' }] }, { i: { [Symbol('gt')]: 1 } }, { i: { [Op.or]: [{ [Op.eq]: 1 }], [Op.gt]: 10 } }, { i: { [Op.or]: [] } }, { i: { [Op.and]: 0 } }, { [Op.and]: [{ s: 'a' }] }, { i: { [Op.in]: [1] } }]) {
127
+ throwsMsg(() => cvFind(f, LT.ctx), 'symbol key is not allowed')
128
+ }
129
+ throwsMsg(() => cvFind({ s: 'a', [Symbol.toStringTag]: 'Q' }, LT.ctx), 'condition must be a plain object')
130
+ //Op全集 × 欄位層與文件層
131
+ let n = 0
132
+ for (let name of Object.keys(Op)) {
133
+ for (let f of [{ i: { [Op[name]]: 1 } }, { [Op[name]]: [{ i: 1 }] }, { i: { $gt: 1, [Op[name]]: 1 } }]) {
134
+ throwsMsg(() => cvFind(f, LT.ctx), 'symbol key is not allowed')
135
+ n++
136
+ }
137
+ }
138
+ assert.ok(n >= 90, `被檢核之案數過少: ${n}`)
139
+ })
140
+
141
+ it('R2 同欄多運算子併入同一欄位物件; 不可列舉之鍵reject', function() {
142
+ let w = LT.where({ i: { $gt: 5, $lt: 9 } })
143
+ assert.ok(w.indexOf('`i` > 5') >= 0 && w.indexOf('`i` < 9') >= 0, w)
105
144
  let f = {}
106
145
  Object.defineProperty(f, 's', { value: 'a', enumerable: false })
107
- assert.ok(LT.where(f).indexOf('`s` = \'a\'') >= 0)
146
+ throwsMsg(() => cvFind(f, LT.ctx), 'non-enumerable property is not allowed')
108
147
  })
109
148
 
110
149
  it('R2 欄名__proto__與空字串拒收', function() {
111
- throwsMsg(() => cvFind(JSON.parse('{"__proto__":1}'), LT.ctx), 'invalid column name')
112
- throwsMsg(() => cvFind({ '': 1 }, LT.ctx), 'invalid column name')
150
+ throwsMsg(() => cvFind(JSON.parse('{"__proto__":1}'), LT.ctx), 'unsupported column name [__proto__]')
151
+ throwsMsg(() => cvFind({ '': 1 }, LT.ctx), 'unsupported column name []')
113
152
  })
114
153
 
115
154
  it('R3 不支援之運算子一律拒收', function() {
@@ -122,37 +161,39 @@ describe('cvFind', function() {
122
161
  }
123
162
  })
124
163
 
125
- it('R3 邏輯運算子須為非空之物件陣列, 陣列不得有洞', function() {
126
- for (let op of ['$and', '$or', '$nor']) {
127
- throwsMsg(() => cvFind({ [op]: [] }, LT.ctx), 'nonempty array')
128
- throwsMsg(() => cvFind({ [op]: { s: 1 } }, LT.ctx), 'requires an array')
129
- throwsMsg(() => cvFind({ [op]: ['x'] }, LT.ctx), 'condition must be an object')
130
- throwsMsg(() => cvFind({ [op]: new Array(2) }, LT.ctx), 'holes or undefined')
164
+ it('R3 邏輯運算子須為非空之物件陣列, 陣列不得有洞, 元素不得為空條件; $nor、$exists已移除', function() {
165
+ for (let op of ['$and', '$or']) {
166
+ throwsMsg(() => cvFind({ [op]: [] }, LT.ctx), `${op} with empty array`)
167
+ throwsMsg(() => cvFind({ [op]: { s: 1 } }, LT.ctx), 'value must be an array')
168
+ throwsMsg(() => cvFind({ [op]: ['x'] }, LT.ctx), 'condition must be a plain object')
169
+ throwsMsg(() => cvFind({ [op]: new Array(2) }, LT.ctx), 'sparse array hole')
170
+ throwsMsg(() => cvFind({ [op]: [{}] }, LT.ctx), 'empty condition')
131
171
  }
132
- throwsMsg(() => cvFind({ [Op.and]: [] }, LT.ctx), 'nonempty array')
133
- throwsMsg(() => cvFind({ i: { $in: [1, undefined] } }, LT.ctx), 'holes or undefined')
134
- throwsMsg(() => cvFind({ i: { $exists: 'yes' } }, LT.ctx), '$exists must be a boolean, 0 or 1')
172
+ throwsMsg(() => cvFind({ $nor: [{ s: 'a' }] }, LT.ctx), 'unsupported operator [$nor]')
173
+ throwsMsg(() => cvFind({ $nor: [] }, LT.ctx), 'unsupported operator [$nor]')
174
+ throwsMsg(() => cvFind({ i: { $in: [1, undefined] } }, LT.ctx), 'undefined is not allowed')
175
+ throwsMsg(() => cvFind({ i: { $exists: true } }, LT.ctx), 'unsupported operator [$exists]')
135
176
  })
136
177
 
137
178
  it('R4 非純量值不得交sequelize之型別stringify', function() {
138
179
  let bads = [
139
- [{ d: { $lt: { x: 1 } } }, 'value must be'],
140
- [{ f: { $gt: [1] } }, 'value must be'],
141
- [{ i: { $in: [{}] } }, 'value must be'],
142
- [{ i: NaN }, 'number must be finite'],
143
- [{ i: Infinity }, 'number must be finite'],
180
+ [{ d: { $lt: { x: 1 } } }, 'unsupported value type'],
181
+ [{ f: { $gt: [1] } }, 'unsupported value type [array]'],
182
+ [{ i: { $in: [{}] } }, 'unsupported value type'],
183
+ [{ i: NaN }, 'number is not finite'],
184
+ [{ i: Infinity }, 'number is not finite'],
144
185
  [{ d: new Date('x') }, 'invalid Date'],
145
- [{ i: 10n }, 'value must be'],
146
- [{ s: () => 1 }, 'value must be'],
147
- [{ s: Symbol('x') }, 'value must be'],
148
- [{ s: new Map() }, 'value must be'],
149
- [{ s: undefined }, 'value is undefined'],
186
+ [{ i: 10n }, 'unsupported value type'],
187
+ [{ s: () => 1 }, 'unsupported value type'],
188
+ [{ s: Symbol('x') }, 'unsupported value type'],
189
+ [{ s: new Map() }, 'unsupported value type'],
190
+ [{ s: undefined }, 'undefined is not allowed'],
150
191
  [{ i: {} }, 'empty operator object'],
151
- [{ s: { a: 1 } }, 'nested object is not supported'],
192
+ [{ s: { a: 1 } }, 'unexpected key'],
152
193
  [{ i: { $gt: null } }, 'null is not allowed'],
153
- [{ i: { $in: 1 } }, 'must be an array'],
154
- [{ i: { $not: [1] } }, '$not does not accept an array'],
155
- [{ i: { $not: {} } }, 'empty operator object'],
194
+ [{ i: { $in: 1 } }, 'value must be an array'],
195
+ [{ i: { $not: [1] } }, 'unsupported operator [$not]'],
196
+ [{ i: { $not: {} } }, 'unsupported operator [$not]'],
156
197
  ]
157
198
  for (let [f, msg] of bads) {
158
199
  throwsMsg(() => cvFind(f, LT.ctx), msg)
@@ -173,6 +214,8 @@ describe('cvFind', function() {
173
214
  [{ dec: '1.254' }, '[num] with scale [2]'],
174
215
  [{ d: { $lt: 1e16 } }, '[date]'],
175
216
  [{ d: -8.64e15 - 1 }, '[date]'],
217
+ [{ d: 8.64e15 }, '[date]'], //Date可表示但UTC年275760, 超出1–9999(審計02 外審 opus [2])
218
+ [{ d: '0000-01-01T00:00:00Z' }, '[date]'], //年0000不在1–9999(年0001–0099之ISO字串見「R4 DATE」ISO字串之年0001–0099)
176
219
  [{ i: Number('9007199254740993') }, '[int]'],
177
220
  [{ big: Number('9007199254740993') }, '[int]'],
178
221
  [{ f: { $lt: 'abc' } }, '[num]'],
@@ -190,10 +233,10 @@ describe('cvFind', function() {
190
233
  for (let [f, msg] of bads) {
191
234
  throwsMsg(() => cvFind(f, LT.ctx), `does not match column type ${msg}`)
192
235
  }
193
- //Buffer只用於未分類型別之欄
194
- throwsMsg(() => cvFind({ s: Buffer.from('a') }, LT.ctx), 'value must be')
236
+ //Buffer不屬白名單純量(第33項批1: 原僅用於未分類型別之欄)
237
+ throwsMsg(() => cvFind({ s: Buffer.from('a') }, LT.ctx), 'unsupported value type')
195
238
  //相符者放行
196
- for (let f of [{ i: '2' }, { i: '-25' }, { f: '-2.5' }, { dec: '.5' }, { dec: '1.25' }, { dec: 1.254 }, { d: 8.64e15 }, { big: '9007199254740993' }, { p: 2.5 }, { d: { $lt: '2020-01-01T00:00:00Z' } }, { d: { $lt: '2020-01-01 08:00:00+08:00' } }, { d: { $lt: 1577836800000 } }, { do: '2020-01-01' }, { b: 1 }, { nocol: 1 }]) {
239
+ for (let f of [{ i: '2' }, { i: '-25' }, { f: '-2.5' }, { dec: '.5' }, { dec: '1.25' }, { dec: 1.254 }, { d: 253402300799999 }, { big: '9007199254740993' }, { p: 2.5 }, { d: { $lt: '2020-01-01T00:00:00Z' } }, { d: { $lt: '2020-01-01 08:00:00+08:00' } }, { d: { $lt: 1577836800000 } }, { do: '2020-01-01' }, { b: 1 }]) {
197
240
  assert.doesNotThrow(() => cvFind(f, LT.ctx), JSON.stringify(f))
198
241
  }
199
242
  })
@@ -204,43 +247,14 @@ describe('cvFind', function() {
204
247
  throwsMsg(() => cvFind(o, LT.ctx), 'too deep')
205
248
  })
206
249
 
207
- it('R6 $regex跳脫LIKE萬用字元並帶ESCAPE, mssql為N前綴', function() {
208
- assert.ok(LT.where({ t: { $regex: '_' } }).indexOf('LIKE \'%!_%\' ESCAPE \'!\'') >= 0)
209
- assert.ok(LT.where({ t: { $regex: '%' } }).indexOf('LIKE \'%!%%\' ESCAPE \'!\'') >= 0)
210
- assert.ok(LT.where({ t: { $regex: '!' } }).indexOf('LIKE \'%!!%\' ESCAPE \'!\'') >= 0)
211
- assert.ok(LT.where({ t: { $regex: '\\[a-c\\]' } }).indexOf('LIKE \'%![a-c]%\' ESCAPE \'!\'') >= 0)
212
- assert.ok(LT.where({ t: { $regex: 'a\\.c' } }).indexOf('LIKE \'%a.c%\'') >= 0)
213
- assert.ok(MS.where({ t: { $regex: '_' } }).indexOf('LIKE N\'%!_%\' ESCAPE \'!\'') >= 0)
214
- assert.ok(MS.where({ t: { $regex: '中' } }).indexOf('LIKE N\'%中%\'') >= 0)
215
- })
216
-
217
- it('R6 $regex只收字面子字串, 旗標只收i', function() {
218
- for (let p of ['^pe', 'a.c', 'a*', '[a]', 'a|b', '(a)', 'a{2}', 'a?', 'a+', 'a$', '\\d', 'a/b', 'a\\']) {
219
- throwsMsg(() => cvFind({ t: { $regex: p } }, LT.ctx), '$regex supports literal substrings only')
220
- }
221
- throwsMsg(() => cvFind({ t: { $regex: 'a' + String.fromCharCode(0) + 'x' } }, LT.ctx), 'NUL')
222
- throwsMsg(() => cvFind({ t: { $regex: 'a', $options: 'm' } }, LT.ctx), 'supports [i] only')
223
- throwsMsg(() => cvFind({ t: { $regex: /a/m } }, LT.ctx), 'supports [i] only')
224
- throwsMsg(() => cvFind({ t: { $regex: /a/, $options: 'i' } }, LT.ctx), '$options can not be used with a RegExp')
225
- throwsMsg(() => cvFind({ t: { $options: 'i' } }, LT.ctx), '$options requires $regex')
226
- throwsMsg(() => cvFind({ t: { $regex: 1 } }, LT.ctx), '$regex must be a string or RegExp')
227
- for (let c of ['i', 'f', 'p', 'dec', 'b', 'd']) {
228
- throwsMsg(() => cvFind({ [c]: { $regex: '1' } }, LT.ctx), '$regex requires a string column')
250
+ it('R6 $regex/$options/RegExp已移除(第33項批1): 一律reject, 不再有LIKE轉譯', function() {
251
+ for (let M of [LT, MS]) {
252
+ for (let f of [{ t: { $regex: 'a' } }, { t: { $regex: 'a', $options: 'i' } }, { t: { $regex: /a/ } }, { t: { $options: 'i' } }, { t: { $not: { $regex: 'a' } } }, { i: { $regex: '1' } }]) {
253
+ throwsMsg(() => cvFind(f, M.ctx), 'unsupported operator')
254
+ }
255
+ throwsMsg(() => cvFind({ t: /a/i }, M.ctx), 'unsupported value type [RegExp]')
256
+ throwsMsg(() => cvFind({ t: { $in: [/a/] } }, M.ctx), 'unsupported value type [RegExp]')
229
257
  }
230
- //同義寫法產生相同之條件
231
- let wi = LT.where({ t: { $regex: 'PeT', $options: 'i' } })
232
- assert.ok(wi.indexOf('LOWER(`t`) LIKE LOWER(\'%PeT%\') ESCAPE \'!\'') >= 0, wi)
233
- assert.strictEqual(LT.where({ t: { $regex: 'PeT', $options: '$i' } }), wi)
234
- assert.strictEqual(LT.where({ t: { $regex: /PeT/i } }), wi)
235
- assert.strictEqual(LT.where({ t: /PeT/i }), wi)
236
- assert.strictEqual(LT.where({ t: { $regex: new RegExp('') } }), LT.where({ t: { $regex: '' } }))
237
- })
238
-
239
- it('R6 給i時LOWER內為實際欄名; sqlite下含非ASCII大小寫字母者拒收', function() {
240
- assert.ok(LT.where({ title: { $regex: 'a', $options: 'i' } }).indexOf('LOWER(`physical_title`)') >= 0)
241
- throwsMsg(() => cvFind({ t: { $regex: 'é', $options: 'i' } }, LT.ctx), 'ASCII letters only')
242
- assert.doesNotThrow(() => cvFind({ t: { $regex: '中', $options: 'i' } }, LT.ctx))
243
- assert.ok(MS.where({ t: { $regex: 'é', $options: 'i' } }).indexOf('LOWER([t]) LIKE LOWER(N\'%é%\')') >= 0)
244
258
  })
245
259
 
246
260
  it('getColKind 依sequelize型別類別分類', function() {
@@ -264,11 +278,9 @@ describe('cvFind', function() {
264
278
  let pairs = [
265
279
  [{ id: 123 }, { id: '123' }],
266
280
  [{ id: { $in: [123, '45'] } }, { id: { $in: ['123', '45'] } }],
267
- [{ id: [1.5] }, { id: ['1.5'] }],
281
+ [{ id: { $nin: [1.5] } }, { id: { $nin: ['1.5'] } }],
268
282
  [{ id: { $gt: 2, $lte: 9 } }, { id: { $gt: '2', $lte: '9' } }],
269
- [{ id: { $not: { $eq: 7 } } }, { id: { $not: { $eq: '7' } } }],
270
- [{ id: { [Op.in]: [7] } }, { id: { [Op.in]: ['7'] } }],
271
- [{ id: { [Op.between]: [1, 2] } }, { id: { [Op.between]: ['1', '2'] } }],
283
+ [{ id: { $ne: 7 } }, { id: { $ne: '7' } }],
272
284
  ]
273
285
  for (let [a, b] of pairs) {
274
286
  assert.strictEqual(LT.where(a, { pk: 'id' }), LT.where(b, { pk: 'id' }))
@@ -276,9 +288,8 @@ describe('cvFind', function() {
276
288
  }
277
289
  assert.ok(LT.where({ id: 123 }, { pk: 'id' }).indexOf('`id` = \'123\'') >= 0)
278
290
  assert.ok(MS.where({ id: 123 }, { pk: 'id' }).indexOf('[id] = N\'123\'') >= 0)
279
- //null依R5, $regex不受影響
291
+ //null依R5
280
292
  assert.ok(LT.where({ id: null }, { pk: 'id' }).indexOf('`id` IS NULL') >= 0)
281
- assert.ok(LT.where({ id: { $regex: '12' } }, { pk: 'id' }).indexOf('LIKE \'%12%\'') >= 0)
282
293
  //整數類主鍵欄: 標準整數寫法字串即數值, 超出安全整數者保留字串
283
294
  assert.strictEqual(LT.where({ i: '12' }, { pk: 'i' }), LT.where({ i: 12 }, { pk: 'i' }))
284
295
  assert.strictEqual(LT.where({ i: { $in: ['-3', 5] } }, { pk: 'i' }), LT.where({ i: { $in: [-3, 5] } }, { pk: 'i' }))
@@ -315,6 +326,819 @@ describe('cvFind', function() {
315
326
  })
316
327
 
317
328
 
329
+ describe('cvFind 審計02', function() {
330
+
331
+ //規格句(CLAUDE_rulebook.md):
332
+ // [最低要求②] 〈條件(find)之支援範圍〉:不支援之運算子與無法轉譯之值必須reject, 不得靜默忽略、改變語意或擴大刪除範圍; ③不得因條件清單恰為空而刪全表
333
+ // [R3] 欄位層運算子產生之每個子式, 其SQL必含該欄名(欄位引用不變式); 常數僅限文件層
334
+ // [R5] $in:[]恆假、$nin:[]恆真(含NULL列)、$not:{$in:[]}為全部; 空集合須引用其欄位且只引用一次
335
+ // [R4欄名解析] 嚴格鍵: 型別與主鍵檢核作用於屬性名或資料庫欄名完全相符之欄(屬性名優先), attrs已給而其餘者reject, 未給attrs者鍵不解析而交資料庫
336
+ // [R4 dateNoTz] ctx.dateNoTz值域、語意、驗證
337
+
338
+ //空集合之各入口(欄名以f代入)
339
+ let emptyFinds = (f) => [
340
+ ['$in:[]', { [f]: { $in: [] } }],
341
+ ['$in:[]+$nin非空', { [f]: { $in: [], $nin: ['x'] } }],
342
+ ['$in:[]+比較運算子', { [f]: { $in: [], $gt: 'a' } }],
343
+ ['$or含空$in', { $or: [{ [f]: { $in: [] } }, { id: 'zz' }] }],
344
+ ['$and含空$in', { $and: [{ [f]: { $in: [] } }, { id: 'zz' }] }],
345
+ ['$or內$and含空$in', { $or: [{ $and: [{ [f]: { $in: [] } }, { id: 'zz' }] }] }],
346
+ ]
347
+
348
+ it('[R3][最低要求②] 空集合之各入口產生之SQL皆引用其欄位, 使資料庫得以回報不存在之欄(attrs已給之欄與未給attrs之任意欄)', function() {
349
+ for (let M of [LT, MS]) {
350
+ for (let f of ['s', 'title']) {
351
+ for (let [lb, find] of emptyFinds(f)) {
352
+ let w = M.where(find)
353
+ assert.ok(w.indexOf(M.q(f)) >= 0, `${lb} × ${f}: SQL未引用欄位: ${w}`)
354
+ }
355
+ }
356
+ //未給attrs: 鍵不解析而交資料庫, 不存在之欄須由資料庫回報, 故SQL須含該欄名
357
+ for (let f of ['nocol', 'nane']) {
358
+ for (let [lb, find] of emptyFinds(f)) {
359
+ let w = M.where0(find)
360
+ assert.ok(w.indexOf(M.q(f)) >= 0, `${lb} × ${f}(未給attrs): SQL未引用欄位: ${w}`)
361
+ }
362
+ }
363
+ }
364
+ })
365
+
366
+ it('[R3][R4最低要求②③] attrs已給時不存在之欄於轉譯階段reject(含空集合各入口、遞迴與包覆), 不依賴資料庫回報', function() {
367
+ for (let M of [LT, MS]) {
368
+ for (let f of ['nocol', 'nane']) {
369
+ for (let [, find] of emptyFinds(f)) {
370
+ throwsMsg(() => M.where(find), `unknown column name [${f}]`)
371
+ }
372
+ }
373
+ }
374
+ })
375
+
376
+ it('[R3][最低要求②] 白名單內之運算子 × 各類值(未給attrs): 凡未被拒收者其SQL皆引用其欄位(防日後新增分支再犯); sequelize之Op Symbol一律於形狀階段reject', function() {
377
+ let vals = [[], [1, 2], ['x'], null, 1, 'x', '', true, false, new Date(0)]
378
+ let n = 0
379
+ for (let M of [LT, MS]) {
380
+ for (let op of ['$eq', '$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin']) {
381
+ for (let v of vals) {
382
+ let w = null
383
+ try {
384
+ w = M.where0({ nocol: { [op]: v } })
385
+ }
386
+ catch (e) {
387
+ continue //拒收者不產生SQL
388
+ }
389
+ n++
390
+ assert.ok(w.indexOf(M.q('nocol')) >= 0, `${op} × ${JSON.stringify(v)}: SQL未引用欄位: ${w}`)
391
+ }
392
+ }
393
+ for (let name of Object.keys(Op)) {
394
+ throwsMsg(() => M.where0({ nocol: { [Op[name]]: 1 } }), 'symbol key is not allowed')
395
+ }
396
+ }
397
+ assert.ok(n > 20, `被檢核之案數過少: ${n}`)
398
+ })
399
+
400
+ it('[R5] 空集合之欄位只引用一次: mssql驅動遇同一語句≥2個錯誤時err.message為空字串', function() {
401
+ //R5: $in:[]以f IS NULL AND 1 = 0表示, 不用f IS NULL AND f IS NOT NULL之雙引用形式($nin:[]已於形狀階段reject)
402
+ for (let M of [LT, MS]) {
403
+ let w = M.where({ s: { $in: [] } })
404
+ assert.strictEqual(w.split(M.q('s')).length - 1, 1, `$in:[]: 欄位引用次數不為1: ${w}`)
405
+ throwsMsg(() => M.where({ s: { $nin: [] } }), '$nin with empty array is always true')
406
+ }
407
+ })
408
+
409
+ it('[R3] 屬性名≠欄名之空集合: 經sequelize映射後SQL含資料庫欄名physical_title(單元之where不經映射, 另以mapWhereFieldNames補)', function() {
410
+ //R3: 欄位層子式之SQL必含該欄名; 實際送出者為映射後之資料庫欄名, 非屬性名
411
+ for (let M of [LT, MS]) {
412
+ for (let [lb, find] of emptyFinds('title')) {
413
+ let w = M.whereDb(find)
414
+ assert.ok(w.indexOf(M.q('physical_title')) >= 0, `${lb}: 映射後SQL未含資料庫欄名: ${w}`)
415
+ assert.strictEqual(w.indexOf(M.q('title')), -1, `${lb}: 映射後SQL仍含屬性名: ${w}`)
416
+ }
417
+ }
418
+ })
419
+
420
+ it('[R5] 同一find含多個空集合時, 各空集合各引用其欄位一次(欄名自然出現多次)', function() {
421
+ for (let M of [LT, MS]) {
422
+ let w = M.where({ $or: [{ s: { $in: [] } }, { s: { $in: [] } }] })
423
+ assert.strictEqual(w.split(M.q('s')).length - 1, 2, w)
424
+ }
425
+ })
426
+
427
+ it('[R4欄名解析] 嚴格鍵: 屬性名或資料庫欄名完全相符者依其型別檢核, 其餘一律reject unknown column name(無折疊、不分大小寫亦不收、無關方言)', function() {
428
+ //規格句: R4欄名解析——鍵須與屬性名或資料庫欄名完全相符, 否則reject; 不模擬資料庫之識別字解析
429
+ let ws = String.fromCharCode(0x3000) //全形空白(mssql預設定序視同尾端空白)
430
+ let fe = String.fromCharCode(0xFEFF) //mssql CI定序下附加於id後仍等同id之可忽略字元(2026-10-16實測)
431
+ let ip = String.fromCharCode(0x26A) //U+026A, mssql CI定序下等同i(2026-10-16實測)
432
+ for (let M of [LT, MS]) {
433
+ //完全相符之屬性名與資料庫欄名(title → physical_title)依其型別檢核
434
+ throwsMsg(() => cvFind({ i: { $lt: 'abc' } }, M.ctx), '[int]')
435
+ throwsMsg(() => cvFind({ f: { $lt: 'abc' } }, M.ctx), '[num]')
436
+ throwsMsg(() => cvFind({ d: { $lt: 'garbage' } }, M.ctx), '[date]')
437
+ throwsMsg(() => cvFind({ title: 5 }, M.ctx), '[str]')
438
+ throwsMsg(() => cvFind({ physical_title: 5 }, M.ctx), '[str]')
439
+ assert.doesNotThrow(() => cvFind({ i: { $lt: 5 } }, M.ctx))
440
+ assert.doesNotThrow(() => cvFind({ physical_title: 'x' }, M.ctx))
441
+ //其餘一律reject: ASCII大小寫變體、全形、尾端空白(含全形空白)、可忽略字元、mssql等價字元、attrs未列之欄、sqlite隱含欄
442
+ //(第33項批1: 含空白、可忽略字元等不屬欄名文法者於形狀階段即reject為unsupported column name; 符合文法者〔字母、數字、底線〕由嚴格鍵reject為unknown column name)
443
+ for (let k of ['I', 'F', 'D', 'PHYSICAL_TITLE', 'TITLE', 'Title', 'i', 'i ', `i${ws}`, `i${fe}`, `${ip}`, ` i`, 'nocol', 'rowid', 'oid', '_rowid_']) {
444
+ let pre = /^[\p{L}_][\p{L}\p{M}\p{N}_]*$/u.test(k) ? 'unknown column name' : 'unsupported column name'
445
+ throwsMsg(() => cvFind({ [k]: { $lt: 'abc' } }, M.ctx), pre)
446
+ throwsMsg(() => cvFind({ [k]: 1 }, M.ctx), pre)
447
+ throwsMsg(() => cvFind({ [k]: { $in: [] } }, M.ctx), pre)
448
+ throwsMsg(() => cvFind({ [k]: { $nin: [1] } }, M.ctx), pre)
449
+ throwsMsg(() => cvFind({ $or: [{ s: 'a' }, { [k]: 1 }] }, M.ctx), pre)
450
+ }
451
+ throwsMsg(() => cvFind({ I: { $lt: 'abc' } }, M.ctx), 'unknown column name [I]')
452
+ }
453
+ })
454
+
455
+ it('[R4欄名解析] 性質: 任一屬性名/資料庫欄名經各種變異(大小寫、全形、空白、可忽略字元、引號、方括號、點、前後$、等價字元、增刪字元)後, 凡不等於某完全相符名者一律reject', function() {
456
+ //規格句: R4欄名解析——只收完全相符者; 以變異窮舉取代逐例(三輪外審皆為再模擬一條規則而漏一個拼寫)
457
+ let cp = (n) => String.fromCharCode(n)
458
+ let full = (s) => s.replace(/[!-~]/g, (c) => cp(c.charCodeAt(0) + 0xFEE0)) //ASCII → 全形
459
+ let muts = [
460
+ (s) => s.toUpperCase(), (s) => s.toLowerCase(), (s) => s[0].toUpperCase() + s.slice(1), (s) => s[0].toLowerCase() + s.slice(1).toUpperCase(),
461
+ (s) => `${s} `, (s) => `${s} `, (s) => ` ${s}`, (s) => `${s}${cp(0x3000)}`, (s) => `${s}${cp(0xFEFF)}`, (s) => `${s}${cp(0xAD)}`, (s) => `${s}${cp(0x200B)}`,
462
+ (s) => `${s}\t`, (s) => `${s}\n`, (s) => `${s}${cp(0)}`, (s) => `${s}${cp(0xA0)}`,
463
+ (s) => `${s.slice(0, 1)}\`${s.slice(1)}`, (s) => `${s}\``, (s) => `${s}"`, (s) => `${s}'`, (s) => `${s.slice(0, 1)}[${s.slice(1)}`, (s) => `${s}]`, (s) => `[${s}]`,
464
+ (s) => `${s}.x`, (s) => `x.${s}`, (s) => `$${s}$`, (s) => `$${s}`, (s) => `${s}$`,
465
+ (s) => full(s), (s) => s.replace(/i/g, cp(0x26A)).replace(/I/g, cp(0x26A)), (s) => s.replace(/e/g, cp(0xE9)), (s) => s.normalize('NFD'),
466
+ (s) => s + s.slice(-1), (s) => s.slice(0, -1), (s) => `x${s}`, (s) => `${s}x`, (s) => s.split('').reverse().join(''),
467
+ ]
468
+ let M0 = mk('sqlite', { ...defs, 'Éa': Sequelize.STRING, 'x y': Sequelize.INTEGER, '中文欄': Sequelize.STRING })
469
+ for (let M of [M0, mk('mssql', { ...defs, 'Éa': Sequelize.STRING, 'x y': Sequelize.INTEGER, '中文欄': Sequelize.STRING })]) {
470
+ let attrs = M.ctx.attrs
471
+ let ok = new Set()
472
+ Object.keys(attrs).forEach((k) => {
473
+ ok.add(k)
474
+ ok.add(attrs[k].field)
475
+ })
476
+ let nRej = 0
477
+ for (let base of ok) {
478
+ if (base.length === 0) {
479
+ continue
480
+ }
481
+ for (let mu of muts) {
482
+ let k = mu(base)
483
+ if (ok.has(k) || k === '__proto__') {
484
+ continue //變異後恰為另一完全相符名(合法)
485
+ }
486
+ for (let v of [{ $lt: 'abc' }, '012', { $in: [] }, { $not: { $gt: 1 } }]) {
487
+ let e = null
488
+ try {
489
+ cvFind({ [k]: v }, M.ctx)
490
+ }
491
+ catch (err) {
492
+ e = err
493
+ }
494
+ assert.ok(e !== null && /unknown column name|unsupported column name|invalid column name|unsupported operator/.test(e.message), `未reject: ${JSON.stringify(k)} (源自${JSON.stringify(base)}): ${e && e.message}`)
495
+ nRej++
496
+ }
497
+ }
498
+ }
499
+ assert.ok(nRej > 1000, `被檢核之案數過少: ${nRej}`)
500
+ }
501
+ })
502
+
503
+ it('[R4欄名解析] unknown column name 之提示僅供診斷: 不分大小寫相同者附did you mean, 解析結果不變(仍reject)', function() {
504
+ throwsMsg(() => cvFind({ I: 1 }, LT.ctx), 'unknown column name [I], did you mean [i]?')
505
+ throwsMsg(() => cvFind({ PHYSICAL_TITLE: 'x' }, MS.ctx), 'did you mean [title]?')
506
+ let e = null
507
+ try {
508
+ cvFind({ nocol: 1 }, LT.ctx)
509
+ }
510
+ catch (err) {
511
+ e = err
512
+ }
513
+ assert.ok(e !== null && e.message.indexOf('did you mean') < 0, '無相近者不附提示')
514
+ })
515
+
516
+ it('[R4欄名解析] 優先序與歧義: 屬性名完全相符 > 資料庫欄名完全相符; 欄名重複reject; 僅大小寫不同之屬性各依完全相符', function() {
517
+ //屬性a之欄名為b、屬性b之欄名為c: {b:..}依屬性名完全相符為屬性b(INTEGER, 其欄c反查即b)
518
+ let M1 = mk('sqlite', { id: pkStr, a: { type: Sequelize.STRING, field: 'b' }, b: { type: Sequelize.INTEGER, field: 'c' } })
519
+ throwsMsg(() => cvFind({ b: 'x' }, M1.ctx), '[int]')
520
+ assert.doesNotThrow(() => cvFind({ b: 5 }, M1.ctx))
521
+ //屬性名無相符時, 資料庫欄名完全相符者
522
+ throwsMsg(() => cvFind({ c: 'x' }, M1.ctx), '[int]')
523
+ //屬性a之欄b恰為屬性b之名稱: sequelize反查為屬性b, 見「撞名」
524
+ throwsMsg(() => cvFind({ a: 'x' }, M1.ctx), 'ambiguous column name [a]')
525
+ //僅大小寫不同之兩屬性(只可能存在於區分大小寫之資料庫; sqlite與mssql預設定序下無法建表, 此處僅驗證各依完全相符之純函數行為): 各依完全相符; 其他拼寫reject
526
+ let M2 = mk('sqlite', { id: pkStr, Ab: Sequelize.STRING, aB: Sequelize.INTEGER })
527
+ assert.doesNotThrow(() => cvFind({ Ab: 'x' }, M2.ctx))
528
+ assert.doesNotThrow(() => cvFind({ aB: 1 }, M2.ctx))
529
+ throwsMsg(() => cvFind({ Ab: 1 }, M2.ctx), '[str]')
530
+ throwsMsg(() => cvFind({ ab: 1 }, M2.ctx), 'unknown column name [ab]')
531
+ //資料庫欄名重複(兩屬性對應同一欄): 以欄名查無從判定, reject
532
+ let M3 = mk('sqlite', { id: pkStr, a: { type: Sequelize.STRING, field: 'x' }, b: { type: Sequelize.INTEGER, field: 'x' } })
533
+ throwsMsg(() => cvFind({ x: 'a' }, M3.ctx), 'ambiguous column name [x]')
534
+ //屬性名與他屬性之欄名: 屬性名之大小寫變體、欄名之大小寫變體皆不解析
535
+ let M4 = mk('sqlite', { id: pkStr, ab: { type: Sequelize.STRING, field: 'c' }, q: { type: Sequelize.INTEGER, field: 'AB' } })
536
+ throwsMsg(() => cvFind({ aB: 'x' }, M4.ctx), 'unknown column name [aB]')
537
+ assert.doesNotThrow(() => cvFind({ AB: 5 }, M4.ctx))
538
+ throwsMsg(() => cvFind({ AB: 'x' }, M4.ctx), '[int]')
539
+ throwsMsg(() => cvFind({ ab: 5 }, M4.ctx), '[str]')
540
+ //Object.prototype之成員名不是屬性(unsupported column name, 見「不安全欄名」)
541
+ for (let k of ['constructor', 'toString', 'hasOwnProperty', '__defineGetter__']) {
542
+ throwsMsg(() => cvFind({ [k]: 1 }, M1.ctx), `unsupported column name [${k}]`)
543
+ }
544
+ //未給attrs時不解析(型別any, 交資料庫)
545
+ assert.doesNotThrow(() => cvFind({ I: { $lt: 'abc' } }, { escape: M2.ctx.escape, dialect: 'sqlite' }))
546
+ assert.doesNotThrow(() => cvFind({ rowid: 1 }, { escape: M2.ctx.escape, dialect: 'sqlite' }))
547
+ })
548
+
549
+ it('[R4欄名解析][R8] 主鍵欄: 屬性名與資料庫欄名(屬性名≠欄名)皆依R8正規化; 非完全相符之鍵reject', function() {
550
+ //R8: 字串類主鍵欄之數字即String(v)
551
+ assert.ok(LT.where({ id: 123 }, { pk: 'id' }).indexOf('\'123\'') >= 0)
552
+ //整數類主鍵欄: '012'為型別不符, '12'為12; 非主鍵整數欄收'012'
553
+ throwsMsg(() => cvFind({ i: '012' }, { ...LT.ctx, pk: 'i' }), '[int]')
554
+ assert.ok(LT.where({ i: '12' }, { pk: 'i' }).indexOf('`i` = 12') >= 0)
555
+ assert.doesNotThrow(() => cvFind({ i: '012' }, { ...LT.ctx, pk: 'id' }))
556
+ //屬性名≠欄名之主鍵: 屬性id對應欄ID_COL, 兩種完全相符之鍵皆依R8; 變體鍵reject(原於不分大小寫之資料庫繞過R8而刪除id=12)
557
+ let M = mk('sqlite', { id: { type: Sequelize.INTEGER, primaryKey: true, field: 'ID_COL' }, name: Sequelize.STRING })
558
+ let cx = { ...M.ctx, pk: 'id' }
559
+ throwsMsg(() => cvFind({ id: '012' }, cx), '[int]')
560
+ throwsMsg(() => cvFind({ ID_COL: '012' }, cx), '[int]')
561
+ assert.ok(M.where({ ID_COL: '12' }, { pk: 'id' }).indexOf('`ID_COL` = 12') >= 0)
562
+ for (let k of ['ID', 'Id', 'id_col', 'ID_COL ']) {
563
+ let pre = k === 'ID_COL ' ? 'unsupported column name' : 'unknown column name' //尾端空白不屬欄名文法, 於形狀階段reject
564
+ throwsMsg(() => cvFind({ [k]: '012' }, cx), pre)
565
+ throwsMsg(() => cvFind({ [k]: '12' }, cx), pre)
566
+ }
567
+ })
568
+
569
+ it('[R4欄名解析] 不安全欄名: 鍵或其映射後之資料庫欄名會被sequelize改寫或重新解讀者reject(引號/方括號字元、點、前後$、Object.prototype成員名、__proto__)', function() {
570
+ //規格句: R4欄名解析(b)——sequelize之quoteIdentifier刪除引號與方括號字元(sqlite之val`ue成value)、點為JSON路徑與表.欄、$x$為欄引用、
571
+ //原型名使sequelize查到函數而不依欄型序列化、賦值__proto__使條件遺失而刪全表; 這些名稱之比對對象不是該名稱之欄
572
+ for (let dl of ['sqlite', 'mssql']) {
573
+ //使用者之鍵(attrs已給: 非完全相符之此類鍵亦為unsupported而非unknown, 以免依賴其是否碰巧解析)
574
+ let M = mk(dl)
575
+ //($開頭之鍵為運算子、__proto__為R2之invalid column name, 不在此)
576
+ //(訊息中之方括號為避免偽造路徑段而以?取代, 故只比對前綴)
577
+ for (let k of ['val`ue', 'i"d', 'i\'d', 'i[d]', 'i]', 'a.b', 'constructor', 'valueOf', 'hasOwnProperty']) {
578
+ throwsMsg(() => cvFind({ [k]: { $lt: 'abc' } }, M.ctx), 'unsupported column name')
579
+ throwsMsg(() => cvFind({ [k]: '012' }, { ...M.ctx, pk: 'i' }), 'unsupported column name')
580
+ }
581
+ //屬性名安全, 但其映射後之資料庫欄名不安全(model之field為此類名稱, 如既有資料表之欄)
582
+ for (let fd of ['meta.a', '$id$', 'a`b', 'x[y]', '__proto__', 'constructor', 'valueOf', 't.name', 'it\'s']) {
583
+ let M2 = mk(dl, { id: pkStr, v: { type: Sequelize.INTEGER, field: fd }, at: { type: Sequelize.DATE, field: fd === 'valueOf' ? fd : 'dt' } })
584
+ throwsMsg(() => cvFind({ v: 1 }, M2.ctx), `unsupported column name [${fd}]`)
585
+ throwsMsg(() => cvFind({ v: { $gt: 1 } }, M2.ctx), `unsupported column name [${fd}]`)
586
+ }
587
+ //DATE欄之欄名為valueOf: 以屬性名查亦reject(sequelize查到Object.prototype.valueOf而不依欄型序列化)
588
+ let M3 = mk(dl, { id: pkStr, ts: { type: Sequelize.DATE, field: 'valueOf' } })
589
+ throwsMsg(() => cvFind({ ts: { $gt: 1893456000000 } }, M3.ctx), 'unsupported column name [valueOf]')
590
+ throwsMsg(() => cvFind({ valueOf: { $gt: 1893456000000 } }, M3.ctx), 'unsupported column name [valueOf]')
591
+ //含$者不屬欄名文法(第33項批1: p$x原為既有model之欄而可查詢, 現於形狀階段reject)
592
+ throwsMsg(() => cvFind({ 'p$x': 'a' }, M.ctx), 'unsupported column name [p$x]')
593
+ }
594
+ })
595
+
596
+ it('[R4欄名解析] VIRTUAL屬性非資料庫欄: 與資料庫欄僅大小寫不同之VIRTUAL屬性不得使其成為該欄之別名', function() {
597
+ //run04 opus [8]: model {value:INTEGER, Value:VIRTUAL}, {Value:{$lt:'abc'}} 原由完全相符收下而不檢型別, sqlite不分大小寫解析到value欄而全命中
598
+ for (let dl of ['sqlite', 'mssql']) {
599
+ let M = mk(dl, { id: pkStr, value: Sequelize.INTEGER, Value: Sequelize.VIRTUAL(Sequelize.STRING) })
600
+ throwsMsg(() => cvFind({ Value: { $lt: 'abc' } }, M.ctx), 'unknown column name [Value]')
601
+ throwsMsg(() => cvFind({ value: { $lt: 'abc' } }, M.ctx), '[int]')
602
+ assert.doesNotThrow(() => cvFind({ value: { $lt: 5 } }, M.ctx))
603
+ //VIRTUAL屬性有field時, 以其欄名為鍵亦非資料庫欄(與屬性名鍵同一判斷; run05 opus [9])
604
+ let M2 = mk(dl, { id: pkStr, v: { type: Sequelize.VIRTUAL(Sequelize.STRING), field: 'vc' } })
605
+ throwsMsg(() => cvFind({ vc: 'a' }, M2.ctx), 'unknown column name [vc]')
606
+ throwsMsg(() => cvFind({ v: 'a' }, M2.ctx), 'unknown column name [v]')
607
+ }
608
+ })
609
+
610
+ it('[R4] 欄位運算子物件須至少一個運算子: 僅有值為undefined之$options、空物件皆reject(原條件消失而成恆真, delAll刪全表); $options已移除', function() {
611
+ for (let M of [LT, MS]) {
612
+ throwsMsg(() => cvFind({ s: { $options: undefined } }, M.ctx), 'unsupported operator [$options]')
613
+ throwsMsg(() => cvFind({ $or: [{ s: { $options: undefined } }, { id: 'a' }] }, M.ctx), 'unsupported operator [$options]')
614
+ throwsMsg(() => cvFind({ s: { $options: 'i' } }, M.ctx), 'unsupported operator [$options]')
615
+ throwsMsg(() => cvFind({ s: {} }, M.ctx), 'empty operator object')
616
+ throwsMsg(() => cvFind({ $or: [{ s: {} }, { id: 'a' }] }, M.ctx), 'empty operator object')
617
+ }
618
+ })
619
+
620
+ it('[R2][最低要求①] cvFind輸出之每個一般物件最多一個欄位字串鍵(sequelize之mapWhereFieldNames於同一物件內遇{屬性:..,欄名:..}會互相覆寫而丟掉條件)', function() {
621
+ let walk = (o, acc) => {
622
+ if (Array.isArray(o)) {
623
+ o.forEach((x) => walk(x, acc))
624
+ }
625
+ else if (o !== null && typeof o === 'object' && Object.getPrototypeOf(o) === Object.prototype) {
626
+ acc.push(Object.keys(o).length)
627
+ Reflect.ownKeys(o).forEach((k) => walk(o[k], acc))
628
+ }
629
+ }
630
+ for (let M of [LT, MS]) {
631
+ for (let find of [
632
+ { title: { $gt: 'a', $lt: 'z' }, physical_title: { $ne: 'q' } },
633
+ { title: { $in: ['a'], $nin: ['b'], $eq: null } },
634
+ { $or: [{ i: 1 }, { i: { $in: [] } }], s: { $in: [] } },
635
+ { d: { $gte: new Date(0), $lte: new Date(1), $gt: 1 } },
636
+ ]) {
637
+ let acc = []
638
+ walk(cvFind(find, M.ctx), acc)
639
+ assert.ok(acc.length > 0 && acc.every((n) => n <= 1), JSON.stringify(acc))
640
+ }
641
+ }
642
+ })
643
+
644
+ it('[R4 DATE] 所有形式換算UTC後年份須在1–9999(sqlite以文字比較, 年10000起為5位數而命中全部日期列); 邊界放行', function() {
645
+ let bads = [
646
+ { d: { $gt: new Date(253402300800000) } }, //UTC 10000-01-01
647
+ { d: { $gt: 253402300800000 } },
648
+ { d: { $gt: '9999-12-31T23:30:00-05:00' } }, //UTC 10000-01-01T04:30
649
+ { d: { $lt: new Date(-62135596800001) } }, //UTC 0000-12-31T23:59:59.999
650
+ { d: { $lt: -62135596800001 } },
651
+ ]
652
+ for (let f of bads) {
653
+ throwsMsg(() => cvFind(f, LT.ctx), '[date]')
654
+ }
655
+ for (let f of [{ d: { $gt: '9999-12-31T23:59:59Z' } }, { d: { $gt: new Date(253402300799999) } }, { d: { $lt: new Date(-62135596800000) } }]) {
656
+ assert.doesNotThrow(() => cvFind(f, LT.ctx), JSON.stringify(f))
657
+ }
658
+ //dateNoTz: 本地時間通過回驗, 但換算UTC後年份超出者拒收
659
+ withTz('America/New_York', () => {
660
+ throwsMsg(() => cvFind({ d: { $gt: '9999-12-31T23:30:00' } }, { ...LT.ctx, dateNoTz: 'local' }), '[date]')
661
+ assert.doesNotThrow(() => cvFind({ d: { $gt: '9999-12-31T18:30:00' } }, { ...LT.ctx, dateNoTz: 'local' }))
662
+ })
663
+ })
664
+
665
+ it('[R4 DATE] 通過檢核之字串與epoch一律轉為Date送出: 與直接給同一瞬間之Date之SQL相同(令DATE值之SQL與sequelize之欄位查找無關)', function() {
666
+ //各入口(欄位直給、$eq…$lte、$in/$nin、邏輯結合)皆須經同一轉換, 任一入口漏轉則該入口之字串/epoch與Date之SQL不同; 預期之Date由同一瞬間獨立給出
667
+ let entries = [
668
+ ['等值', (k, v) => ({ [k]: v })],
669
+ ['$eq', (k, v) => ({ [k]: { $eq: v } })],
670
+ ['$ne', (k, v) => ({ [k]: { $ne: v } })],
671
+ ['$gt', (k, v) => ({ [k]: { $gt: v } })],
672
+ ['$gte', (k, v) => ({ [k]: { $gte: v } })],
673
+ ['$lt', (k, v) => ({ [k]: { $lt: v } })],
674
+ ['$lte', (k, v) => ({ [k]: { $lte: v } })],
675
+ ['$in', (k, v) => ({ [k]: { $in: [v, v] } })],
676
+ ['$nin', (k, v) => ({ [k]: { $nin: [v] } })],
677
+ ['$or內', (k, v) => ({ $or: [{ s: 'a' }, { [k]: v }] })],
678
+ ['$and內$or', (k, v) => ({ $and: [{ $or: [{ s: 'a' }, { [k]: { $lt: v } }] }] })],
679
+ ]
680
+ let t2019 = new Date('2019-01-01T00:00:00Z')
681
+ let t2030 = new Date(1893456000000)
682
+ for (let M of [LT, MS]) {
683
+ for (let [v, dt] of [['2019-01-01T00:00:00Z', t2019], ['2019-01-01 08:00:00+08:00', t2019], [1893456000000, t2030], [t2019, t2019]]) {
684
+ for (let [lb, mkf] of entries) {
685
+ assert.strictEqual(M.where(mkf('d', v)), M.where(mkf('d', dt)), `${lb} ${String(v)}`)
686
+ }
687
+ }
688
+ //epoch與字串不以原樣送出
689
+ assert.strictEqual(M.where({ d: { $gt: 1893456000000 } }).indexOf('1893456000000'), -1)
690
+ assert.strictEqual(M.where({ d: '2019-01-01T00:00:00Z' }).indexOf('T00:00:00Z'), -1)
691
+ }
692
+
693
+ //run05 sol F09: 上列SQL比較不能鎖住「cvFind必須轉Date」——欄位型別可由sequelize查得時, 字串與epoch未轉換而原樣交出, sequelize亦依欄型序列化成相同SQL(實測76/76格相同);
694
+ //故直接斷言cvFind之輸出: 不含原樣之字串/epoch, 且含Date
695
+ let leaves = (o, acc) => {
696
+ if (Array.isArray(o)) {
697
+ o.forEach((x) => leaves(x, acc))
698
+ }
699
+ else if (o instanceof Date) {
700
+ acc.push(o)
701
+ }
702
+ else if (o !== null && typeof o === 'object' && Object.getPrototypeOf(o) === Object.prototype) {
703
+ Reflect.ownKeys(o).forEach((k) => leaves(o[k], acc))
704
+ }
705
+ else if (o === null || typeof o !== 'object') {
706
+ acc.push(o)
707
+ }
708
+ }
709
+ for (let [v, ms] of [['2019-01-01T00:00:00Z', 1546300800000], ['2019-01-01 08:00:00+08:00', 1546300800000], [1893456000000, 1893456000000]]) {
710
+ for (let [lb, mkf] of entries) {
711
+ let acc = []
712
+ leaves(cvFind(mkf('d', v), LT.ctx), acc)
713
+ assert.ok(acc.indexOf(v) < 0, `${lb} ${String(v)}: 輸出仍含原樣之字串/epoch`)
714
+ let ds = acc.filter((x) => x instanceof Date)
715
+ assert.ok(ds.length > 0 && ds.every((x) => x.getTime() === ms), `${lb} ${String(v)}: 輸出之Date不等於${ms}`)
716
+ }
717
+ }
718
+ })
719
+
720
+ it('[R4 dateNoTz] 值域: undefined或\'local\', 其餘拋錯(含find為空或非物件時)', function() {
721
+ assert.doesNotThrow(() => cvFind({ d: '2026-01-01T00:00:00Z' }, { ...LT.ctx, dateNoTz: undefined }))
722
+ assert.doesNotThrow(() => cvFind({ d: '2026-01-01T00:00:00Z' }, { ...LT.ctx, dateNoTz: 'local' }))
723
+ for (let v of ['Local', 'utc', 'UTC', null, false, true, '', 0, {}]) {
724
+ throwsMsg(() => cvFind({ d: '2026-01-01T00:00:00Z' }, { ...LT.ctx, dateNoTz: v }), 'invalid ctx: dateNoTz must be \'local\' or undefined')
725
+ }
726
+ //驗證早於提前返回, 拼錯不會被靜默忽略
727
+ throwsMsg(() => cvFind({}, { ...LT.ctx, dateNoTz: 'Local' }), 'invalid ctx')
728
+ throwsMsg(() => cvFind(undefined, { ...LT.ctx, dateNoTz: 'Local' }), 'invalid ctx')
729
+ })
730
+
731
+ it('[R4 dateNoTz] 未給時維持reject(R4:無時區者依主機時區解析而結果隨主機而變, 預設拒收)', function() {
732
+ for (let s of ['2026-01-01T00:00:00', '2026-01-01 00:00', '2026-01-01T00:00:00.123']) {
733
+ throwsMsg(() => cvFind({ d: s }, LT.ctx), '[date]')
734
+ throwsMsg(() => cvFind({ d: s }, { ...LT.ctx, dateNoTz: undefined }), '[date]')
735
+ }
736
+ })
737
+
738
+ //[字串, 本地時間分量[y,m,d,h,mi,s,ms]]
739
+ let okForms = [
740
+ ['2026-01-01T00:00:00', [2026, 1, 1, 0, 0, 0, 0]],
741
+ ['2026-01-01 00:00:00', [2026, 1, 1, 0, 0, 0, 0]],
742
+ ['2026-01-01T00:00', [2026, 1, 1, 0, 0, 0, 0]],
743
+ ['2026-06-15T08:30:15', [2026, 6, 15, 8, 30, 15, 0]],
744
+ ['2026-06-15T08:30:15.1', [2026, 6, 15, 8, 30, 15, 100]],
745
+ ['2026-06-15T08:30:15.123', [2026, 6, 15, 8, 30, 15, 123]],
746
+ ['2026-06-15T08:30:15.123456', [2026, 6, 15, 8, 30, 15, 123]], //Q-C C3: 截斷至毫秒
747
+ ['2024-02-29T12:00:00', [2024, 2, 29, 12, 0, 0, 0]],
748
+ ['2026-12-31T23:59:59.999', [2026, 12, 31, 23, 59, 59, 999]],
749
+ ]
750
+
751
+ it('[R4 dateNoTz] \'local\': 無時區字串 ≡ 以本地時間分量建構之Date(SQL相同), 於各主機時區', function() {
752
+ for (let tz of ['UTC', 'Asia/Taipei', 'America/New_York', 'Australia/Lord_Howe']) {
753
+ withTz(tz, () => {
754
+ for (let M of [LT, MS]) {
755
+ for (let [s, a] of okForms) {
756
+ let dt = new Date(a[0], a[1] - 1, a[2], a[3], a[4], a[5], a[6])
757
+ for (let [lb, mkFind] of [
758
+ ['等值', (v) => ({ d: v })],
759
+ ['$eq', (v) => ({ d: { $eq: v } })],
760
+ ['$gt', (v) => ({ d: { $gt: v } })],
761
+ ['$gte', (v) => ({ d: { $gte: v } })],
762
+ ['$lt', (v) => ({ d: { $lt: v } })],
763
+ ['$lte', (v) => ({ d: { $lte: v } })],
764
+ ['$ne', (v) => ({ d: { $ne: v } })],
765
+ ['$in', (v) => ({ d: { $in: [v] } })],
766
+ ['$nin', (v) => ({ d: { $nin: [v] } })],
767
+ ['$or內', (v) => ({ $or: [{ s: 'a' }, { d: v }] })],
768
+ ]) {
769
+ assert.strictEqual(M.where(mkFind(s), { dateNoTz: 'local' }), M.where(mkFind(dt)), `${tz} ${lb} ${s}`)
770
+ }
771
+ }
772
+ }
773
+ })
774
+ }
775
+ })
776
+
777
+ it('[R4 dateNoTz] \'local\': 結果隨主機時區而變(此即其語意)', function() {
778
+ let at = (tz, s) => withTz(tz, () => LT.where({ d: s }, { dateNoTz: 'local' }))
779
+ assert.ok(at('UTC', '2026-01-01T00:00:00').indexOf('2026-01-01 00:00:00.000 +00:00') >= 0)
780
+ assert.ok(at('America/New_York', '2026-01-01T00:00:00').indexOf('2026-01-01 05:00:00.000 +00:00') >= 0) //EST
781
+ assert.ok(at('America/New_York', '2026-07-01T00:00:00').indexOf('2026-07-01 04:00:00.000 +00:00') >= 0) //EDT
782
+ assert.ok(at('Asia/Tokyo', '2026-01-01T00:00:00').indexOf('2025-12-31 15:00:00.000 +00:00') >= 0)
783
+ })
784
+
785
+ it('[R4 dateNoTz] \'local\': 曆法上或主機時區下不存在之時刻拒收, 夏令重疊取較早者', function() {
786
+ let bads = [
787
+ '2026-02-30T00:00:00', '2026-02-29T12:00:00', '2026-13-01T00:00:00', '2026-00-10T00:00:00', '2026-01-01T24:00:00', '2026-01-01T23:60:00',
788
+ '2026-01-01T23:59:60', '2026-01-01T25:00:00',
789
+ ]
790
+ for (let s of bads) {
791
+ throwsMsg(() => cvFind({ d: s }, { ...LT.ctx, dateNoTz: 'local' }), '[date]')
792
+ }
793
+ withTz('America/New_York', () => {
794
+ throwsMsg(() => cvFind({ d: '2026-03-08T02:30:00' }, { ...LT.ctx, dateNoTz: 'local' }), '[date]') //夏令起始跳過之時刻
795
+ assert.ok(LT.where({ d: '2026-11-01T01:30:00' }, { dateNoTz: 'local' }).indexOf('2026-11-01 05:30:00.000 +00:00') >= 0) //重疊取EDT(較早)
796
+ })
797
+ })
798
+
799
+ it('[R4 dateNoTz] \'local\': 只放寬無時區之日期時間字串(Q-C C2:純日期仍拒收); 其餘格式與R4同', function() {
800
+ let bads = ['2026-01-01', '0026-01-01T00:00:00', '12026-01-01T00:00:00', '-002026-01-01T00:00:00', '2026-01-01t00:00:00', ' 2026-01-01T00:00:00', '2026-01-01T00:00:00 ', '2026-1-1T00:00:00', '', 'garbage', '2026-01-01T00:00:00.', '2026-01-01T00:00:00Zx',
801
+ '2026-01-00T00:00:00', '2026-00-01T00:00:00', '2026-01-01\t00:00:00', '2026-01-01 00:00:00', '2026-01-01T00:00.123', '2026-01-01T0:00:00', '02026-01-01T00:00:00', '2026-01-01T00:00:00\n']
802
+ for (let s of bads) {
803
+ throwsMsg(() => cvFind({ d: s }, { ...LT.ctx, dateNoTz: 'local' }), '[date]')
804
+ }
805
+ //帶時區者與未給dateNoTz同(皆轉為Date送出)
806
+ for (let s of ['2026-01-01T00:00:00Z', '2026-01-01T00:00:00+08:00', '2026-01-01 00:00:00+0800']) {
807
+ assert.strictEqual(LT.where({ d: s }, { dateNoTz: 'local' }), LT.where({ d: s }))
808
+ }
809
+ //Date與epoch照舊
810
+ assert.doesNotThrow(() => cvFind({ d: new Date(0) }, { ...LT.ctx, dateNoTz: 'local' }))
811
+ assert.doesNotThrow(() => cvFind({ d: 0 }, { ...LT.ctx, dateNoTz: 'local' }))
812
+ //$in內含一個不合法者整體拒收
813
+ throwsMsg(() => cvFind({ d: { $in: ['2026-01-01T00:00:00', '2026-02-30T00:00:00'] } }, { ...LT.ctx, dateNoTz: 'local' }), '[date]')
814
+ })
815
+
816
+ it('[R4 dateNoTz] \'local\': 僅作用於DATE欄; DATEONLY、字串、整數欄與無法解析之欄不受影響', function() {
817
+ let cx = { ...LT.ctx, dateNoTz: 'local' }
818
+ throwsMsg(() => cvFind({ do: '2026-01-01T00:00:00' }, cx), '[dateonly]')
819
+ throwsMsg(() => cvFind({ i: '2026-01-01T00:00:00' }, cx), '[int]')
820
+ //字串欄與未給attrs(欄位型別any)之欄: 原字串送出, 不轉換
821
+ assert.ok(LT.where({ s: '2026-01-01T00:00:00' }, { dateNoTz: 'local' }).indexOf('\'2026-01-01T00:00:00\'') >= 0)
822
+ assert.ok(LT.where0({ nocol: '2026-01-01T00:00:00' }, { dateNoTz: 'local' }).indexOf('\'2026-01-01T00:00:00\'') >= 0)
823
+ })
824
+
825
+ //外審修正輪2(聚焦再審 run03): 以下為對resolveAttr、折疊、DATE年份與轉Date四處之再審所得
826
+ let pkStr = { type: Sequelize.STRING, primaryKey: true }
827
+
828
+ it('[R4欄名解析] 屬性之欄名恰為他屬性之名稱或與他屬性共欄: sequelize以映射後之欄名反查屬性而依他屬性之型別序列化值, 反查所得非本屬性者一律reject(不論型別類別)', function() {
829
+ for (let dl of ['sqlite', 'mssql']) {
830
+ let M = (dfs) => mk(dl, { id: pkStr, ...dfs })
831
+ let msg = 'ambiguous column name [a]'
832
+
833
+ //撞名: 屬性a之欄名b恰為屬性b之名稱(sequelize把鍵a映射為b後以rawAttributes.b之型別序列化: DATE值被INTEGER之NUMBER._stringify轉成Date.toString文字, sqlite以文字比較而命中全部非NULL列)
834
+ let M1 = M({ a: { type: Sequelize.DATE, field: 'b' }, b: { type: Sequelize.INTEGER, field: 'c' } })
835
+ for (let f of [{ a: { $lt: '2026-01-01T00:00:00Z' } }, { a: { $gt: 1893456000000 } }, { a: new Date(0) }, { a: { $in: ['2026-01-01T00:00:00Z'] } }, { a: { $nin: [1893456000000] } }, { a: { $gte: 0, $lte: 1893456000000 } }]) {
836
+ throwsMsg(() => cvFind(f, M1.ctx), msg)
837
+ }
838
+ //反向(A為INTEGER、B為DATE): {a:{$lt:5}}會被序列化為日期而語意全變
839
+ let M2 = M({ a: { type: Sequelize.INTEGER, field: 'b' }, b: { type: Sequelize.DATE, field: 'c' } })
840
+ throwsMsg(() => cvFind({ a: { $lt: 5 } }, M2.ctx), msg)
841
+ //不撞名之入口照常: 屬性b(欄c)、資料庫欄名c; 大小寫變體不解析
842
+ for (let f of [{ b: 5 }, { c: 5 }]) {
843
+ assert.doesNotThrow(() => cvFind(f, M1.ctx), JSON.stringify(f))
844
+ }
845
+ throwsMsg(() => cvFind({ b: 'abc' }, M1.ctx), '[int]')
846
+ throwsMsg(() => cvFind({ c: 'abc' }, M1.ctx), '[int]')
847
+ throwsMsg(() => cvFind({ C: 5 }, M1.ctx), 'unknown column name [C]')
848
+
849
+ //撞名但型別類別相同亦reject: 以「反查所得即本屬性」為唯一判準, 不倚賴型別類別相同即序列化相同(自訂DataType、any類別之序列化可能不同; run04 opus [7]、sol)
850
+ let M3 = M({ a: { type: Sequelize.DATE, field: 'b' }, b: { type: Sequelize.DATE, field: 'c' } })
851
+ throwsMsg(() => cvFind({ a: { $lt: '2026-01-01T00:00:00Z' } }, M3.ctx), msg)
852
+ let M3a = M({ a: { type: Sequelize.TIME, field: 'b' }, b: { type: Sequelize.BLOB, field: 'c' } })
853
+ throwsMsg(() => cvFind({ a: { $lt: '12:00' } }, M3a.ctx), msg)
854
+
855
+ //兩屬性共用一欄: sequelize取最後定義者(fieldRawAttributesMap), 先定義者reject, 最後定義者照常
856
+ let M4 = M({ a: { type: Sequelize.DATE, field: 'x' }, b: { type: Sequelize.INTEGER, field: 'x' } })
857
+ throwsMsg(() => cvFind({ a: { $lt: '2026-01-01T00:00:00Z' } }, M4.ctx), msg)
858
+ assert.doesNotThrow(() => cvFind({ b: { $lt: 5 } }, M4.ctx))
859
+ throwsMsg(() => cvFind({ x: 5 }, M4.ctx), 'ambiguous column name [x]')
860
+
861
+ //屬性名即欄名者不受影響(含屬性名大小寫不同之他屬性)
862
+ let M5 = M({ a: { type: Sequelize.DATE }, A: { type: Sequelize.INTEGER } })
863
+ assert.doesNotThrow(() => cvFind({ a: { $lt: '2026-01-01T00:00:00Z' } }, M5.ctx))
864
+ assert.doesNotThrow(() => cvFind({ A: { $lt: 5 } }, M5.ctx))
865
+ }
866
+ })
867
+
868
+ it('[R4 DATE] ISO字串之年0001–0099與Date、epoch同(曆法驗證不受Date.UTC對兩位數年之映射影響); 年0000與UTC年0000拒收', function() {
869
+ for (let M of [LT, MS]) {
870
+ assert.strictEqual(M.where({ d: '0001-01-01T00:00:00Z' }), M.where({ d: new Date(-62135596800000) }))
871
+ assert.strictEqual(M.where({ d: { $gt: '0001-01-01T00:00:00Z' } }), M.where({ d: { $gt: -62135596800000 } }))
872
+ let t50 = new Date(0) //0050-06-01T00:00:00Z, 以setUTCFullYear獨立求出(Date.UTC與new Date(y,..)皆把0–99年映射為1900–1999)
873
+ t50.setUTCFullYear(50, 5, 1)
874
+ assert.strictEqual(M.where({ d: '0050-06-01T00:00:00Z' }), M.where({ d: t50 }))
875
+ assert.ok(M.where({ d: '0050-06-01T00:00:00Z' }).indexOf('0050-06-01 00:00:00.000 +00:00') >= 0)
876
+ assert.ok(M.where({ d: '0099-12-31 23:59:59+00:00' }).indexOf('0099-12-31 23:59:59.000 +00:00') >= 0)
877
+ assert.doesNotThrow(() => cvFind({ d: '0004-02-29T00:00:00Z' }, M.ctx)) //閏年
878
+ for (let s of ['0000-01-01T00:00:00Z', '0001-01-01T00:00:00+00:01', '0100-02-29T00:00:00Z', '0001-02-29T00:00:00Z', '0050-13-01T00:00:00Z']) {
879
+ throwsMsg(() => cvFind({ d: s }, M.ctx), '[date]')
880
+ }
881
+ //DATEONLY同用曆法驗證: 年0001–0099放行, 年0000與不存在之日期拒收
882
+ assert.doesNotThrow(() => cvFind({ do: '0001-01-01' }, M.ctx))
883
+ assert.doesNotThrow(() => cvFind({ do: '0099-12-31' }, M.ctx))
884
+ for (let s of ['0000-01-01', '0001-02-29', '0100-02-29']) {
885
+ throwsMsg(() => cvFind({ do: s }, M.ctx), '[dateonly]')
886
+ }
887
+ }
888
+ })
889
+
890
+ it('[R4 DATE] 非整數epoch依new Date(v)截斷至整數毫秒, -0即0', function() {
891
+ for (let M of [LT, MS]) {
892
+ assert.strictEqual(M.where({ d: 1780272000000.9 }), M.where({ d: 1780272000000 }))
893
+ assert.strictEqual(M.where({ d: -0 }), M.where({ d: 0 }))
894
+ assert.strictEqual(M.where({ d: { $gte: 1780272000000.9 } }), M.where({ d: { $gte: 1780272000000 } }))
895
+ }
896
+ })
897
+
898
+ it('[R4欄名解析] 手造attrs(非define後之rawAttributes): field未設、屬性值為null或字串者不崩潰, 退化為以屬性名為欄名; field已設而非非空字串者reject(見「field須為非空字串」); 欄名含孤立代理字元不崩潰而於形狀階段依unsupported column name reject', function() {
899
+ let attrs = { id: { type: Sequelize.STRING }, a: { type: Sequelize.INTEGER }, b: null, c: 'x', d: { type: Sequelize.DATE, field: undefined } }
900
+ let cx = { escape: LT.ctx.escape, attrs, dialect: 'sqlite' }
901
+ assert.doesNotThrow(() => cvFind({ a: 1, b: 2, c: 3, d: new Date(0) }, cx))
902
+ throwsMsg(() => cvFind({ a: 'abc' }, cx), '[int]')
903
+ throwsMsg(() => cvFind({ A: 'abc' }, cx), 'unknown column name [A]')
904
+ throwsMsg(() => cvFind({ d: 'garbage' }, cx), '[date]')
905
+ for (let dl of ['sqlite', 'mssql']) {
906
+ throwsMsg(() => cvFind({ '\uD800': 1 }, { ...cx, dialect: dl }), 'unsupported column name')
907
+ }
908
+ })
909
+
910
+ })
911
+
912
+
913
+ describe('cvFind 最終審(run05)', function() {
914
+
915
+ //規格句(CLAUDE_rulebook.md R4欄名解析、R2、R4 原生Symbol):
916
+ // [R4欄名解析(b)] 鍵或其映射後之資料庫欄名為sequelize於轉成SQL前改寫或重新解讀者reject: 空字串(_joinKeyValue遇空鍵只回值)、首尾皆$(isColString只比首尾字元)、field非非空字串
917
+ // [R4欄名解析(c)] sequelize之反查須即本屬性, 且共用一欄時只收最後定義者(實體欄之型別由最後定義者決定)
918
+ // [R4] attrs須為sequelize之rawAttributes形狀(一般物件、無Symbol鍵、自有字串鍵皆可列舉), 否則cvFind與sequelize看到不同之屬性集合
919
+ // [R2][最低要求②] 原生Symbol須為sequelize於欄位層可翻譯者(OperatorMap所有者), between/notBetween之值須為兩元素陣列
920
+ let pkStr = { type: Sequelize.STRING, primaryKey: true }
921
+
922
+ it('[R4欄名解析] 不安全欄名: 映射後之欄名為空字串、或首尾皆$(含中間有換行、僅單一$)者reject(run05 sol F01/F02、opus [1]、自查)', function() {
923
+ //空字串: sequelize之sync以field||name建欄(欄名為屬性名)、寫入亦視為未設, 但mapWhereFieldNames把鍵映射為'', _joinKeyValue遇空鍵只回值, 條件成常數(WHERE (5)), delAll刪全表
924
+ //首尾皆$: isColString只比首尾字元, 含換行之$a\nb$被當成欄引用而比對a\nb欄; 單一$被當成空識別字
925
+ for (let dl of ['sqlite', 'mssql']) {
926
+ for (let fd of ['', '$', '$$', '$\n$', '$a\nb$', '$a\r\nb$', '$a\u2028b$', '$a\u2029b$']) {
927
+ let M = mk(dl, { id: pkStr, v: { type: Sequelize.STRING, field: fd } })
928
+ for (let find of [{ v: 'a' }, { v: { $in: ['a'] } }, { v: { $gt: 'a' } }, { v: { $nin: ['a'] } }, { v: { $in: [] } }, { $or: [{ v: null }, { id: 'a' }] }]) {
929
+ throwsMsg(() => cvFind(find, M.ctx), `unsupported column name [${fd}]`)
930
+ }
931
+ }
932
+ //前後皆$之判定與isColString一致: 僅首或僅尾為$者照常
933
+ for (let fd of ['$a', 'a$', 'a\n$', '$\na']) {
934
+ let M = mk(dl, { id: pkStr, v: { type: Sequelize.STRING, field: fd } })
935
+ assert.doesNotThrow(() => cvFind({ v: 'a' }, M.ctx), JSON.stringify(fd))
936
+ }
937
+ }
938
+ })
939
+
940
+ it('[R4欄名解析] 屬性之field已設者須為字串(空字串見「不安全欄名」): null、數值、布林、物件、陣列皆reject(sequelize保留原值並以String(field)映射, cvFind若退回屬性名則二者指向不同欄; run05 opus [4])', function() {
941
+ for (let fd of [null, 0, 123, true, false, {}, []]) {
942
+ let attrs = { id: { type: Sequelize.STRING }, v: { type: Sequelize.INTEGER, field: fd } }
943
+ throwsMsg(() => cvFind({ v: 1 }, { escape: LT.ctx.escape, attrs, dialect: 'sqlite' }), 'unsupported column name [v]')
944
+ throwsMsg(() => cvFind({ v: { $gt: 'a' } }, { escape: LT.ctx.escape, attrs: { ...attrs, v: { type: Sequelize.STRING, field: fd } }, dialect: 'sqlite' }), 'unsupported column name [v]')
945
+ }
946
+ //field未設(undefined)者以屬性名為欄名
947
+ let attrs = { id: { type: Sequelize.STRING }, v: { type: Sequelize.INTEGER, field: undefined }, w: { type: Sequelize.INTEGER } }
948
+ assert.doesNotThrow(() => cvFind({ v: 1, w: 2 }, { escape: LT.ctx.escape, attrs, dialect: 'sqlite' }))
949
+ })
950
+
951
+ it('[R4欄名解析] 共用一欄: 欄名恰為先定義者之名稱時該先定義者亦reject(實體欄之型別由最後定義者決定, sequelize卻依先定義者之型別序列化; run05 opus [2])', function() {
952
+ //屬性a(DATE)之欄名即a, 屬性b(INTEGER)亦映射到欄a: sync以欄名為鍵而後者覆寫, 實體欄a為INTEGER; sequelize把{a:..}依rawAttributes.a(DATE)序列化為文字,
953
+ //sqlite下INTEGER < TEXT恆真, delAll刪光全部非NULL列
954
+ for (let dl of ['sqlite', 'mssql']) {
955
+ let M = mk(dl, { id: pkStr, a: { type: Sequelize.DATE }, b: { type: Sequelize.INTEGER, field: 'a' } })
956
+ for (let f of [{ a: { $lt: '2030-01-01T00:00:00Z' } }, { a: { $gt: 1893456000000 } }, { a: new Date(0) }, { a: null }]) {
957
+ throwsMsg(() => cvFind(f, M.ctx), 'ambiguous column name [a]')
958
+ }
959
+ //b之欄名a反查為屬性a(撞名), 欄名鍵a有兩個屬性
960
+ throwsMsg(() => cvFind({ b: { $lt: 5 } }, M.ctx), 'ambiguous column name [b]')
961
+ //兩屬性共用非屬性名之欄x: 先定義者reject、最後定義者照常(同「撞名」段M4)
962
+ let M4 = mk(dl, { id: pkStr, a: { type: Sequelize.DATE, field: 'x' }, b: { type: Sequelize.INTEGER, field: 'x' } })
963
+ throwsMsg(() => cvFind({ a: { $lt: '2030-01-01T00:00:00Z' } }, M4.ctx), 'ambiguous column name [a]')
964
+ assert.doesNotThrow(() => cvFind({ b: { $lt: 5 } }, M4.ctx))
965
+ //欄名恰為屬性名者(b之欄為c, 另有屬性a之欄為b): 仍依「撞名」reject, 不受影響之入口照常
966
+ let M5 = mk(dl, { id: pkStr, a: { type: Sequelize.INTEGER }, b: { type: Sequelize.INTEGER } })
967
+ assert.doesNotThrow(() => cvFind({ a: 1, b: 2 }, M5.ctx))
968
+ }
969
+ })
970
+
971
+ it('[R4] attrs形狀: 須為一般物件(原型為Object.prototype或null)、無Symbol鍵、自有字串鍵皆可列舉, 否則拋invalid ctx(sequelize讀取之屬性集合與cvFind不同; run05 sol F05/F06/F07)', function() {
972
+ let base = () => ({ id: { type: Sequelize.STRING, primaryKey: true }, i: { type: Sequelize.INTEGER }, d: { type: Sequelize.DATE, field: 'dt' } })
973
+ let cx = (attrs) => ({ escape: LT.ctx.escape, attrs, dialect: 'sqlite' })
974
+
975
+ //Symbol鍵: 與Op.and相撞時, mapWhereFieldNames把邏輯鍵Op.and映射為欄d, {i:1}之條件消失而改為比對d
976
+ let a1 = base()
977
+ a1[Op.and] = { type: Sequelize.DATEONLY, field: 'd', fieldName: Op.and }
978
+ throwsMsg(() => cvFind({ i: 1 }, cx(a1)), 'invalid ctx: attrs')
979
+
980
+ //原型非一般物件: sequelize以rawAttributes[欄名]查找會讀到繼承之定義(cvFind以自有屬性核對則看不到)
981
+ let a2 = Object.setPrototypeOf(base(), { dt: { type: new Sequelize.INTEGER(), field: 'dt', fieldName: 'dt' } })
982
+ throwsMsg(() => cvFind({ d: { $lt: '2020-01-01T00:00:00Z' } }, cx(a2)), 'invalid ctx: attrs')
983
+
984
+ //自有而不可列舉: Object.keys建索引看不到, hasOwnProperty卻看得到
985
+ let a3 = base()
986
+ Object.defineProperty(a3, 'a', { value: { type: Sequelize.INTEGER, field: 'x' }, enumerable: false })
987
+ throwsMsg(() => cvFind({ a: 1 }, cx(a3)), 'invalid ctx: attrs')
988
+ throwsMsg(() => cvFind({ x: 1 }, cx(a3)), 'invalid ctx: attrs')
989
+
990
+ //非物件之attrs(字串、數值、陣列)
991
+ for (let bad of ['abc', 5, [], () => {}]) {
992
+ throwsMsg(() => cvFind({ i: 1 }, cx(bad)), 'invalid ctx: attrs')
993
+ }
994
+
995
+ //原型為null、一般物件、真正之rawAttributes皆照常
996
+ assert.doesNotThrow(() => cvFind({ i: 1 }, cx(Object.assign(Object.create(null), base()))))
997
+ assert.doesNotThrow(() => cvFind({ i: 1 }, cx(base())))
998
+ assert.doesNotThrow(() => cvFind({ i: 1, d: new Date(0) }, LT.ctx))
999
+ assert.doesNotThrow(() => cvFind({ i: 1 }, MS.ctx))
1000
+
1001
+ //attrs未給(undefined、null)者不檢, 維持現況; find為空者提前返回
1002
+ assert.doesNotThrow(() => cvFind({ i: 1 }, { escape: LT.ctx.escape, dialect: 'sqlite' }))
1003
+ assert.deepStrictEqual(cvFind({}, cx(a1)), {})
1004
+ })
1005
+
1006
+ })
1007
+
1008
+
1009
+ describe('cvFind 第33項批1(R11 系列條件白名單接線)', function() {
1010
+
1011
+ //規格句(CLAUDE_rulebook.md R11): cvFind第一步經wsemi之cvOrmFind(maxList:999)檢核並取複本, 其後只走訪複本; 對已檢核之複本再呼叫為冪等(w-orm-mdb於其normFind後再呼叫cvFind);
1012
+ //$and/$or清單超過32項者分組為平衡樹(SQLite運算式樹深度上限); ctx.escape與ctx.dialect已不使用
1013
+
1014
+ let pkS = { type: Sequelize.STRING, primaryKey: true }
1015
+ //案例表之欄位: id name age g t flag d
1016
+ let defsF = { id: pkS, name: Sequelize.STRING, age: Sequelize.INTEGER, g: Sequelize.STRING, t: Sequelize.STRING, flag: Sequelize.BOOLEAN, d: Sequelize.DATE }
1017
+ let MF = [mk('sqlite', defsF), mk('mssql', defsF)]
1018
+
1019
+ let tryOf = (fn) => {
1020
+ try {
1021
+ return { v: fn() }
1022
+ }
1023
+ catch (err) {
1024
+ return { err: String(err.message) }
1025
+ }
1026
+ }
1027
+
1028
+ it('案例表: 每案cvFind(find)與cvFind(chkFindShape複本)結果相同(冪等); REJ案訊息即cvOrmFind之訊息', function() {
1029
+ let n = 0
1030
+ for (let M of MF) {
1031
+ for (let c of cvCases.cases) {
1032
+ let a = tryOf(() => M.where(c.find()))
1033
+ let b = tryOf(() => M.where(chkFindShape(c.find())))
1034
+ assert.deepStrictEqual(a, b, c.id)
1035
+ n++
1036
+ if (c.expect === 'REJ') {
1037
+ let o = tryOf(() => cvOrmFind(c.find(), { maxList: 999 }))
1038
+ assert.ok(a.err !== undefined, `${c.id} 應reject`)
1039
+ assert.strictEqual(a.err, o.err, c.id)
1040
+ assert.ok(a.err.indexOf(c.why) >= 0, `${c.id}: ${a.err}`)
1041
+ }
1042
+ else if (a.err !== undefined) {
1043
+ assert.ok(c.schema === true, `非schema案不得reject: ${c.id} ${a.err}`)
1044
+ }
1045
+ }
1046
+ }
1047
+ assert.ok(n >= 500, `案數 ${n}`)
1048
+ })
1049
+
1050
+ it('ctx.escape、ctx.dialect已不使用: 給或不給、給任意值結果相同; 空物件回{}', function() {
1051
+ for (let M of MF) {
1052
+ let f = { g: 'A', $or: [{ age: { $in: [20, 30] } }, { d: { $gte: new Date(0) } }] }
1053
+ let a = M.where(f)
1054
+ assert.strictEqual(M.where(f, { escape: undefined, dialect: undefined }), a)
1055
+ assert.strictEqual(M.where(f, { escape: () => 'X', dialect: 'nonsense' }), a)
1056
+ }
1057
+ assert.deepStrictEqual(cvFind({}, {}), {})
1058
+ assert.deepStrictEqual(cvFind(undefined, {}), {})
1059
+ assert.deepStrictEqual(cvFind(Object.create(null), {}), {})
1060
+ })
1061
+
1062
+ it('$and/$or清單與並列欄位之隱含AND分組: 項數超過4者遞迴二分為平衡樹, 項數與順序不變, 邏輯節點層數僅隨項數對數成長(4項以內維持平鋪)', function() {
1063
+ //leaves, 逐層展開僅含單一Op.and/Op.or鍵之節點取葉, mx.v記最深邏輯節點層數
1064
+ let leaves = (o, acc, lv, mx) => {
1065
+ let ks = Reflect.ownKeys(o)
1066
+ if (ks.length === 1 && (ks[0] === Op.or || ks[0] === Op.and) && Array.isArray(o[ks[0]])) {
1067
+ mx.v = Math.max(mx.v, lv + 1)
1068
+ o[ks[0]].forEach((x) => leaves(x, acc, lv + 1, mx))
1069
+ }
1070
+ else {
1071
+ acc.push(o)
1072
+ }
1073
+ }
1074
+ //[項數, 預期最深邏輯節點層數]: 頂層Op.and → 清單節點 →(每超過4項之二分一層)→ 各子條件之Op.and; 4項以內為3, 5項起每次折半加1層
1075
+ for (let op of ['$or', '$and']) {
1076
+ for (let [n, lvExp] of [[1, 3], [4, 3], [5, 4], [8, 4], [9, 5], [32, 6], [33, 7], [999, 11]]) {
1077
+ let find = { [op]: Array.from({ length: n }, (_, i) => ({ id: `k${i}` })) }
1078
+ let acc = []
1079
+ let mx = { v: 0 }
1080
+ leaves(cvFind(find, LT.ctx), acc, 0, mx)
1081
+ assert.deepStrictEqual(acc.map((x) => x.id), Array.from({ length: n }, (_, i) => `k${i}`), `${op} ${n}: 葉之順序與數量`)
1082
+ assert.strictEqual(mx.v, lvExp, `${op} ${n}: 邏輯節點層數`)
1083
+ }
1084
+ }
1085
+ //同一條件物件內並列之欄位(隱含AND)亦分組: 40欄 → 葉數與順序不變, 層數僅對數成長
1086
+ let wide = Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`c${i}`, i]))
1087
+ let dfs = { id: { type: Sequelize.STRING, primaryKey: true } }
1088
+ for (let k of Object.keys(wide)) {
1089
+ dfs[k] = Sequelize.INTEGER
1090
+ }
1091
+ let Mw = mk('sqlite', dfs)
1092
+ let accW = []
1093
+ let mxW = { v: 0 }
1094
+ leaves(cvFind(wide, Mw.ctx), accW, 0, mxW)
1095
+ assert.deepStrictEqual(accW.map((x) => Object.keys(x)[0]), Object.keys(wide), '並列欄位之順序與數量')
1096
+ assert.ok(mxW.v <= 8, `並列40欄之邏輯節點層數 ${mxW.v}`)
1097
+ //SQL: 999項之連接詞恆為998個(n項共n-1個), 子條件逐一出現
1098
+ for (let M of [LT, MS]) {
1099
+ for (let [op, kw] of [['$or', ' OR '], ['$and', ' AND ']]) {
1100
+ let w = M.where({ [op]: Array.from({ length: 999 }, (_, i) => ({ id: `k${i}` })) })
1101
+ assert.strictEqual((w.match(/'k\d+'/g) || []).length, 999, `${op}: 子條件數`)
1102
+ assert.strictEqual(w.split(kw).length - 1, 998, `${op}: 連接詞數`)
1103
+ }
1104
+ }
1105
+ })
1106
+
1107
+ it('檢查之值即使用之值: 走訪不第二次讀取原物件(存取器不被呼叫, 以所讀之複本轉譯)', function() {
1108
+ let n = 0
1109
+ let o = {}
1110
+ Object.defineProperty(o, 'g', {
1111
+ enumerable: true,
1112
+ get() {
1113
+ n++
1114
+ return 'A'
1115
+ }
1116
+ })
1117
+ throwsMsg(() => cvFind(o, LT.ctx), 'accessor property is not allowed')
1118
+ assert.strictEqual(n, 0, '存取器不得被呼叫')
1119
+ //Proxy: 描述子陷阱每個鍵只被讀一次
1120
+ let reads = 0
1121
+ let p = new Proxy({ g: 'A', age: 20 }, {
1122
+ getOwnPropertyDescriptor(t, k) {
1123
+ reads++
1124
+ return Reflect.getOwnPropertyDescriptor(t, k)
1125
+ }
1126
+ })
1127
+ assert.ok(MF[0].where(p).indexOf('`g` = \'A\'') >= 0)
1128
+ assert.strictEqual(reads, 2, '2個鍵各讀1次')
1129
+ })
1130
+
1131
+ it('匯出簽名不變(w-orm-mdb深引用): cvFind(find, ctx)、getColKind、getColScale、normPk; 新增chkFindShape', function() {
1132
+ assert.strictEqual(cvFind.length, 2)
1133
+ assert.strictEqual(getColKind.length, 2)
1134
+ assert.strictEqual(normPk.length, 2)
1135
+ assert.strictEqual(typeof chkFindShape, 'function')
1136
+ assert.strictEqual(chkFindShape.length, 1)
1137
+ })
1138
+
1139
+ })
1140
+
1141
+
318
1142
  describe('normPk', function() {
319
1143
 
320
1144
  //rulebook R8: 依主鍵欄型別正規化主鍵值, 與w-orm-mdb之normPk同一規則(主控複審run03第二節之表)