w-orm-reladb 1.1.8 → 1.1.9

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,6 +1,7 @@
1
1
  import assert from 'assert'
2
2
  import Sequelize from 'sequelize'
3
3
  import wo from '../src/WOrmReladb.mjs'
4
+ import cvFind from '../src/cvFind.mjs'
4
5
 
5
6
 
6
7
  //本檔為mssql與sqlite共用之測試套組, 令兩種後端以同一組斷言驗證, 避免各自漂移
@@ -1003,6 +1004,27 @@ export function defineSuites(label, getOpt) {
1003
1004
  ['DATE 以epoch數值比較', { d: { $lt: Date.parse('2020-01-01T00:00:00Z') } }, ['r1']],
1004
1005
  ['同義之$lt與Op.lt', { i: { $lt: 3, [Op.lt]: 100 } }, ['r1', 'r2']],
1005
1006
  ['$not $in [null]為非null', { i: { $not: { $in: [null] } } }, NN],
1007
+
1008
+ //審計02(規格句 R5): 欄名正確時, 空集合之各入口語意不變——$in:[]恆假、$nin:[]恆真且含NULL列(r6)、$not:{$in:[]}為全部;
1009
+ //空集合改為引用其欄位(f IS NULL AND 1 = 0 / f IS NULL OR 1 = 1)後, 命中集合須與原常數形式相同; select命中 = delAll刪除(R1)
1010
+ ['TEXT $in 空陣列為無', { t: { $in: [] } }, []],
1011
+ ['TEXT $nin 空陣列為全部(含NULL列)', { t: { $nin: [] } }, ALL],
1012
+ ['TEXT 陣列值[]為無', { t: [] }, []],
1013
+ ['Op.in 空陣列為無', { i: { [Op.in]: [] } }, []],
1014
+ ['Op.notIn 空陣列為全部', { i: { [Op.notIn]: [] } }, ALL],
1015
+ ['$not $nin 空陣列為無', { t: { $not: { $nin: [] } } }, []],
1016
+ ['Symbol Op.not 包 Op.notIn 空陣列為無', { i: { [Op.not]: { [Op.notIn]: [] } } }, []],
1017
+ ['$in空與$nin空並存為無', { t: { $in: [], $nin: [] } }, []],
1018
+ ['$or 含空$nin 為全部', { $or: [{ t: { $nin: [] } }, { s: 'zz' }] }, ALL],
1019
+ ['$and 含空$in 為無', { $and: [{ t: { $in: [] } }, { s: 's1' }] }, []],
1020
+ ['$nor 含空$in 為全部', { $nor: [{ t: { $in: [] } }] }, ALL],
1021
+ ['$nor 含空$nin 為無', { $nor: [{ t: { $nin: [] } }] }, []],
1022
+ ['文件層Op.not 包空$in 為全部', { [Op.not]: { t: { $in: [] } } }, ALL],
1023
+ ['主鍵欄 $nin 空陣列為全部', { id: { $nin: [] } }, ALL],
1024
+ ['主鍵欄 $in 空陣列為無', { id: { $in: [] } }, []],
1025
+ ['DATE 欄 $nin 空陣列為全部', { d: { $nin: [] } }, ALL],
1026
+ ['BOOLEAN 欄 $in 空陣列為無', { b: { $in: [] } }, []],
1027
+ ['空$nin 與其他欄條件AND', { $and: [{ t: { $nin: [] } }, { i: { $gt: 3 } }] }, ['r4', 'r5']],
1006
1028
  //BOOLEAN之比較依1/0
1007
1029
  ['BOOLEAN $gt false', { b: { $gt: false } }, ['r1', 'r3', 'r5']],
1008
1030
  ['BOOLEAN $not $gt true', { b: { $not: { $gt: true } } }, NN],
@@ -1076,6 +1098,30 @@ export function defineSuites(label, getOpt) {
1076
1098
  ['原型上之條件', Object.create({ s: 's1' }), 'find must be a plain object'],
1077
1099
  ['帶toStringTag之物件', { s: 's1', [Symbol.toStringTag]: 'Q' }, 'unsupported symbol operator'],
1078
1100
  ['欄位層Op.or空陣列', { i: { [Sequelize.Op.or]: [] } }, 'unsupported symbol operator'],
1101
+
1102
+ //run04(規格句 R4欄名解析 嚴格鍵): 鍵須與屬性名或資料庫欄名完全相符, 其餘一律於轉譯階段reject, 不倚賴資料庫之識別字解析(兩方言同結果、兩函數訊息相同);
1103
+ //原sqlite下型別不符之變體鍵使delAll刪除全部非NULL列, 並有sqlite隱含欄rowid、引號被sequelize刪除而成既有欄、mssql之全形/尾端空白/可忽略字元等價之繞過
1104
+ ['欄名大小寫變體 × 非數值字串', { I: { $lt: 'abc' } }, 'unknown column name [I]'],
1105
+ ['欄名大小寫變體 × 非日期字串', { D: { $lt: 'garbage' } }, 'unknown column name [D]'],
1106
+ ['欄名全形變體(mssql預設定序視為同一識別字)', { 'i': { $lt: 'abc' } }, 'unknown column name'],
1107
+ ['欄名尾端空白(mssql忽略識別字尾端空白)', { 'i ': { $lt: 'abc' } }, 'unknown column name'],
1108
+ ['欄名尾端全形空白', { ['i' + String.fromCharCode(0x3000)]: { $lt: 'abc' } }, 'unknown column name'],
1109
+ ['欄名附加可忽略字元(mssql預設定序視為同一識別字)', { ['i' + String.fromCharCode(0xFEFF)]: { $lt: 'abc' } }, 'unknown column name'],
1110
+ ['欄名以mssql預設定序之等價字元(U+026A)取代i', { [String.fromCharCode(0x26A)]: { $lt: 'abc' } }, 'unknown column name'],
1111
+ ['sqlite隱含欄rowid(attrs未列)', { rowid: { $lt: 'abc' } }, 'unknown column name [rowid]'],
1112
+ ['欄名含反引號(sqlite之quoteIdentifier刪除之而成i)', { 'i`': { $lt: 'abc' } }, 'unsupported column name'],
1113
+ ['欄名含方括號(mssql之quoteIdentifier刪除之而成i)', { 'i[]': { $lt: 'abc' } }, 'unsupported column name'],
1114
+ ['欄名含單引號', { 'i\'': { $lt: 'abc' } }, 'unsupported column name'],
1115
+ ['欄名含雙引號', { 'i"': { $lt: 'abc' } }, 'unsupported column name'],
1116
+ ['欄名含點(JSON路徑/表.欄)', { 'i.x': { $lt: 'abc' } }, 'unsupported column name'],
1117
+ ['Object.prototype成員名', { constructor: { $lt: 'abc' } }, 'unsupported column name [constructor]'],
1118
+ //run04(sol H1): 僅有值為undefined之$options不產生任何子式, 條件消失而成恆真, delAll刪全表
1119
+ ['僅$options為undefined', { s: { $options: undefined } }, 'operator object has no operator'],
1120
+ //審計02 外審 opus [2](規格句 R4 DATE: 換算UTC後年份須在1–9999): 年10000起 sequelize 以5位數格式化, sqlite 以文字比較致 $gt 命中並刪除全部日期列
1121
+ ['DATE欄收UTC年10000之Date', { d: { $gt: new Date(253402300800000) } }, 'does not match column type [date]'],
1122
+ ['DATE欄收UTC年10000之epoch', { d: { $gt: 253402300800000 } }, 'does not match column type [date]'],
1123
+ ['DATE欄收UTC年10000之ISO字串', { d: { $gt: '9999-12-31T23:30:00-05:00' } }, 'does not match column type [date]'],
1124
+ ['DATE欄收UTC年0000之epoch', { d: { $lt: -62135596800001 } }, 'does not match column type [date]'],
1079
1125
  ['讀值拋錯之getter', Object.defineProperty({}, 's', {
1080
1126
  enumerable: true,
1081
1127
  get: () => {
@@ -1084,6 +1130,31 @@ export function defineSuites(label, getOpt) {
1084
1130
  }), 'getter boom'],
1085
1131
  ]
1086
1132
 
1133
+ //run04(規格句 R3欄位引用不變式、R4嚴格鍵、系列最低要求②③): attrs已給時不存在之欄 × 空集合各入口於轉譯階段reject(原常數形式使delAll清空全表;
1134
+ //HEAD改為引用欄位後仰賴資料庫回報, 現由嚴格鍵直接reject, 兩函數訊息相同, mssql同一語句多次引用不存在之欄而err.message為空之情形不再可達)
1135
+ for (let [t, col, find] of [
1136
+ ['空$in', 'nocol', { nocol: { $in: [] } }],
1137
+ ['空$nin', 'nocol', { nocol: { $nin: [] } }],
1138
+ ['陣列值[]', 'nocol', { nocol: [] }],
1139
+ ['Op.in空陣列', 'nocol', { nocol: { [Sequelize.Op.in]: [] } }],
1140
+ ['Op.notIn空陣列', 'nocol', { nocol: { [Sequelize.Op.notIn]: [] } }],
1141
+ ['$not包空$in', 'nocol', { nocol: { $not: { $in: [] } } }],
1142
+ ['$not包空$nin', 'nocol', { nocol: { $not: { $nin: [] } } }],
1143
+ ['Symbol Op.not包Op.notIn空陣列', 'nocol', { nocol: { [Sequelize.Op.not]: { [Sequelize.Op.notIn]: [] } } }],
1144
+ ['$in空與$nin空並存', 'nocol', { nocol: { $in: [], $nin: [] } }],
1145
+ ['$or含空$nin', 'nocol', { $or: [{ nocol: { $nin: [] } }, { id: 'zz' }] }],
1146
+ ['$and含空$in', 'nocol', { $and: [{ nocol: { $in: [] } }, { id: 'r1' }] }],
1147
+ ['$nor含空$in', 'nocol', { $nor: [{ nocol: { $in: [] } }] }],
1148
+ ['文件層Op.not包空$in', 'nocol', { [Sequelize.Op.not]: { nocol: { $in: [] } } }],
1149
+ ['欄名打錯且排除清單恰為空', 'sttus', { sttus: { $nin: [] } }],
1150
+ ]) {
1151
+ bads.push([`不存在之欄 × ${t}`, find, `unknown column name [${col}]`])
1152
+ }
1153
+ //合法值 × 各欄型之大小寫變體鍵: 不論型別相符與否一律reject(原與屬性名鍵同結果, 見帳本〈破壞性變更紀錄〉)
1154
+ for (let [col, val] of [['s', 's1'], ['t', 'peter'], ['i', 1], ['f', 1.5], ['p', 1.5], ['dec', 1.25], ['b', true], ['d', new Date('2019-01-01T00:00:00Z')], ['d', '2019-01-01T00:00:00Z'], ['d', { $gt: Date.parse('2030-01-01T00:00:00Z') }]]) {
1155
+ bads.push([`欄名大小寫變體(${col}) × 合法值`, { [col.toUpperCase()]: val }, `unknown column name [${col.toUpperCase()}]`])
1156
+ }
1157
+
1087
1158
  for (let [t, find, msg] of bads) {
1088
1159
  it(`不合法條件兩函數皆reject且資料不變: ${t}`, async function() {
1089
1160
  //rulebook R3/R4/R6/R7: 不得靜默忽略或改變語意; 兩函數訊息相同且各發一次error事件
@@ -1315,6 +1386,37 @@ export function defineSuites(label, getOpt) {
1315
1386
  })
1316
1387
  }
1317
1388
 
1389
+
1390
+ //run04(規格句 R4欄名解析 嚴格鍵、R8): 主鍵欄之非完全相符鍵一律於轉譯階段reject, 不倚賴資料庫之識別字解析(原大小寫變體於兩方言、
1391
+ //mssql之全形/尾端空白/可忽略字元等價皆繞過R8而命中並刪除id=12; 合法值之變體亦reject, 見帳本〈破壞性變更紀錄〉)
1392
+ let ws = String.fromCharCode(0x3000)
1393
+ let fe = String.fromCharCode(0xFEFF)
1394
+ let ip = String.fromCharCode(0x26A)
1395
+ let badKeys = [
1396
+ ['欄名大小寫變體+前導零', { ID: '012' }, 'unknown column name [ID]'],
1397
+ ['欄名大小寫變體+整數寫法字串', { ID: '12' }, 'unknown column name [ID]'],
1398
+ ['欄名大小寫變體+$in字串', { Id: { $in: ['12', '5'] } }, 'unknown column name [Id]'],
1399
+ ['欄名大小寫變體+非整數', { Id: 12.4 }, 'unknown column name [Id]'],
1400
+ ['欄名全形變體+前導零', { 'ID': '012' }, 'unknown column name'],
1401
+ ['欄名尾端空白+前導零', { 'id ': '012' }, 'unknown column name'],
1402
+ ['欄名大寫+尾端空白+前導零', { 'ID ': '012' }, 'unknown column name'],
1403
+ ['欄名尾端全形空白+前導零', { ['id' + ws]: '012' }, 'unknown column name'],
1404
+ ['欄名附加可忽略字元+前導零', { ['id' + fe]: '012' }, 'unknown column name'],
1405
+ ['欄名以U+026A取代i+前導零', { [ip + 'd']: '012' }, 'unknown column name'],
1406
+ ['欄名含反引號+前導零', { 'i`d': '012' }, 'unsupported column name'],
1407
+ ['欄名含方括號+前導零', { 'i[d]': '012' }, 'unsupported column name'],
1408
+ ['欄名含單引號+前導零', { 'i\'d': '012' }, 'unsupported column name'],
1409
+ ]
1410
+ for (let [t, find, msg] of badKeys) {
1411
+ it(`select與delAll 主鍵欄之非完全相符鍵皆reject且不刪除: ${t}`, async function() {
1412
+ let rs = await errOf(wi.select(find))
1413
+ let rd = await errOf(wi.delAll(find))
1414
+ assert.ok(rs !== null && rs.indexOf(msg) >= 0, `select: ${rs}`)
1415
+ assert.strictEqual(rd, rs)
1416
+ assert.deepStrictEqual(await ids(), [5, 12])
1417
+ })
1418
+ }
1419
+
1318
1420
  })
1319
1421
 
1320
1422
  describe('DECIMAL主鍵欄', function() {
@@ -1359,5 +1461,428 @@ export function defineSuites(label, getOpt) {
1359
1461
 
1360
1462
  })
1361
1463
 
1464
+ describe(`${label} dateNoTz`, function() {
1465
+
1466
+ //審計02(規格句 R4 ctx.dateNoTz②④⑤): cvFind之ctx.dateNoTz='local'(由呼叫端明示, 供w-orm-mdb等無時區日期後端)——
1467
+ //無時區字串依主機時區解析, select與delAll之命中須等於以本地時間分量建構之Date之命中(命中集合由JS端獨立算出); 不合法值仍reject且資料不變; 未給時維持reject
1468
+ //以cvFind直接轉譯後交sequelize執行(WOrmReladb本身不設定該選項, R4③)
1469
+
1470
+ let sq = null
1471
+ let md = null
1472
+
1473
+ //withTz, 暫時切換主機時區; 還原須以賦值而非delete: Windows上delete process.env.TZ不會還原主機時區(實測仍停在最後設定之時區), 故載入時先記下主機時區名稱
1474
+ let hostTz = Intl.DateTimeFormat().resolvedOptions().timeZone
1475
+ let withTz = async (tz, fn) => {
1476
+ let old = process.env.TZ
1477
+ if (tz !== null) {
1478
+ process.env.TZ = tz
1479
+ }
1480
+ try {
1481
+ return await fn()
1482
+ }
1483
+ finally {
1484
+ if (tz !== null) {
1485
+ if (old !== undefined) {
1486
+ process.env.TZ = old
1487
+ }
1488
+ else if (process.platform === 'win32') {
1489
+ process.env.TZ = hostTz
1490
+ }
1491
+ else {
1492
+ delete process.env.TZ
1493
+ }
1494
+ }
1495
+ }
1496
+ }
1497
+
1498
+ let L = (a) => new Date(a[0], a[1] - 1, a[2], a[3] || 0, a[4] || 0, a[5] || 0, a[6] || 0)
1499
+ let rowsDef = {
1500
+ r1: [2026, 1, 1, 0, 0, 0, 0],
1501
+ r2: [2026, 6, 15, 8, 30, 15, 123],
1502
+ r3: [2026, 6, 15, 8, 30, 15, 0],
1503
+ r4: [2026, 12, 31, 23, 59, 59, 999],
1504
+ }
1505
+ let ALL = ['r1', 'r2', 'r3', 'r4', 'r5']
1506
+ let ctx = (ex = {}) => ({ escape: (v) => sq.escape(v), attrs: md.rawAttributes, dialect: label, pk: 'id', ...ex })
1507
+ let ids = async () => (await md.findAll({ raw: true })).map((v) => v.id).sort()
1508
+ let orc = (fn) => Object.entries(rowsDef).filter(([, a]) => fn(L(a).getTime())).map(([id]) => id).sort()
1509
+ let T = (a) => L(a).getTime()
1510
+ let S0 = [2026, 6, 15, 8, 30, 15, 0]
1511
+
1512
+ before(async function() {
1513
+ let o = getOpt()
1514
+ if (label === 'sqlite') {
1515
+ sq = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
1516
+ }
1517
+ else {
1518
+ let u = new URL(o.url)
1519
+ sq = new Sequelize(o.db, decodeURIComponent(u.username), decodeURIComponent(u.password), { dialect: 'mssql', host: u.hostname, port: Number(u.port), logging: false })
1520
+ }
1521
+ md = sq.define('tdtz', { id: { type: Sequelize.STRING, primaryKey: true }, d: { type: Sequelize.DATE } }, { tableName: 'tdtz', timestamps: false })
1522
+ await md.sync({ force: true })
1523
+ })
1524
+
1525
+ after(async function() {
1526
+ if (md) {
1527
+ await md.drop()
1528
+ }
1529
+ if (sq) {
1530
+ await sq.close()
1531
+ }
1532
+ })
1533
+
1534
+ let seed = async () => {
1535
+ await md.destroy({ where: {} })
1536
+ let rs = Object.entries(rowsDef).map(([id, a]) => ({ id, d: L(a) }))
1537
+ rs.push({ id: 'r5', d: null })
1538
+ await md.bulkCreate(rs)
1539
+ }
1540
+
1541
+ //[說明, find, 預期命中之id的述詞(t: 該列之毫秒時間; 於withTz內以當下時區之本地時間分量求值, 與被測實作無共用程式碼)或id陣列]
1542
+ let cases = [
1543
+ ['等值(T分隔,秒)', { d: '2026-01-01T00:00:00' }, (t) => t === T([2026, 1, 1])],
1544
+ ['等值(空白分隔)', { d: '2026-06-15 08:30:15' }, (t) => t === T(S0)],
1545
+ ['等值(省秒)', { d: '2026-01-01T00:00' }, (t) => t === T([2026, 1, 1])],
1546
+ ['等值(毫秒.123)', { d: '2026-06-15T08:30:15.123' }, (t) => t === T([2026, 6, 15, 8, 30, 15, 123])],
1547
+ ['等值(.123456截斷至123ms)', { d: '2026-06-15T08:30:15.123456' }, (t) => t === T([2026, 6, 15, 8, 30, 15, 123])],
1548
+ ['$gt', { d: { $gt: '2026-06-15T08:30:15' } }, (t) => t > T(S0)],
1549
+ ['$gte', { d: { $gte: '2026-06-15T08:30:15' } }, (t) => t >= T(S0)],
1550
+ ['$lt', { d: { $lt: '2026-06-15T08:30:15' } }, (t) => t < T(S0)],
1551
+ ['$lte', { d: { $lte: '2026-06-15T08:30:15' } }, (t) => t <= T(S0)],
1552
+ ['$ne(SQL三值不含NULL列)', { d: { $ne: '2026-06-15T08:30:15' } }, (t) => t !== T(S0)],
1553
+ ['$in', { d: { $in: ['2026-01-01T00:00:00', '2026-12-31T23:59:59.999'] } }, (t) => t === T([2026, 1, 1]) || t === T([2026, 12, 31, 23, 59, 59, 999])],
1554
+ ['$nin(SQL三值不含NULL列)', { d: { $nin: ['2026-01-01T00:00:00', '2026-12-31T23:59:59.999'] } }, (t) => t !== T([2026, 1, 1]) && t !== T([2026, 12, 31, 23, 59, 59, 999])],
1555
+ ['陣列值即$in', { d: ['2026-01-01T00:00:00', '2026-06-15T08:30:15'] }, (t) => t === T([2026, 1, 1]) || t === T(S0)],
1556
+ ['$not.$gt(SQL三值不含NULL列)', { d: { $not: { $gt: '2026-06-15T08:30:15' } } }, (t) => !(t > T(S0))],
1557
+ ['範圍$gte+$lt', { d: { $gte: '2026-06-15T00:00:00', $lt: '2026-06-16T00:00:00' } }, (t) => t >= T([2026, 6, 15]) && t < T([2026, 6, 16])],
1558
+ ['Op.between', { d: { [Sequelize.Op.between]: ['2026-06-15T08:30:15', '2026-12-31T23:59:59.999'] } }, (t) => t >= T(S0) && t <= T([2026, 12, 31, 23, 59, 59, 999])],
1559
+ ['Op.in', { d: { [Sequelize.Op.in]: ['2026-01-01T00:00:00'] } }, (t) => t === T([2026, 1, 1])],
1560
+ ['$in混有帶時區字串', { d: { $in: ['2026-01-01T00:00:00', '2026-06-15T00:30:15.123Z'] } }, (t) => t === T([2026, 1, 1]) || t === Date.parse('2026-06-15T00:30:15.123Z')],
1561
+ ['null照舊', { d: null }, ['r5']],
1562
+ ]
1563
+
1564
+ for (let tz of [null, 'UTC', 'America/New_York']) {
1565
+ for (let [t, find, pe] of cases) {
1566
+ it(`dateNoTz=local 之 select 與 delAll 命中一致且等於本地時間分量之命中 [TZ=${tz === null ? 'host' : tz}]: ${t}`, async function() {
1567
+ await withTz(tz, async () => {
1568
+ //預期值於該時區下以本地時間分量獨立求出(非經被測實作)
1569
+ let exp = typeof pe === 'function' ? orc(pe) : pe
1570
+ await seed()
1571
+ let w = cvFind(find, ctx({ dateNoTz: 'local' }))
1572
+ let hit = (await md.findAll({ where: w, raw: true })).map((v) => v.id).sort()
1573
+ assert.deepStrictEqual(hit, exp, 'select之命中')
1574
+ let r = await md.destroy({ where: w })
1575
+ let left = await ids()
1576
+ let gone = ALL.filter((v) => left.indexOf(v) < 0)
1577
+ assert.deepStrictEqual(gone, hit, 'delAll之刪除')
1578
+ assert.strictEqual(r, hit.length)
1579
+ })
1580
+ })
1581
+ }
1582
+ }
1583
+
1584
+ //不合法值與未給時之拒收, 於轉譯階段即拋錯(不交給資料庫), 屬純函數行為, 由test/unit-cvfind.test.mjs之「審計02」組逐案斷言
1585
+ //WOrmReladb未接線dateNoTz, 故不在此以select/delAll重述
1586
+ })
1587
+
1588
+
1589
+ describe(`${label} 欄名別名與撞名`, function() {
1590
+
1591
+ //審計02 外審修正輪2、run04 重構(規格句 R4欄名解析〔嚴格鍵〕、R4 DATE通過檢核者轉Date送出; 系列最低要求①②): 屬性名≠欄名之model於資料庫上實跑——
1592
+ //(1)資料庫欄名 ≡ 屬性名: select命中 = delAll刪除 = 由seed數據獨立推得之id集合(大小寫變體等非完全相符之鍵一律reject, 見bads); (2)型別不符reject且資料不變;
1593
+ //(3)撞名(屬性ra之欄名rb恰為屬性rb之名稱, sequelize把鍵ra映射為rb後以rawAttributes.rb之型別序列化值): 反查所得非本屬性者reject(不論型別類別), 資料不變
1594
+ //以cvFind直接轉譯後交sequelize之findAll/destroy執行(與WOrmReladb之select/delAll同一路徑), 同dateNoTz段
1595
+
1596
+ let sq = null
1597
+ let md = null
1598
+ let ALL = ['r1', 'r2', 'r3', 'r4']
1599
+ let ctx = () => ({ escape: (v) => sq.escape(v), attrs: md.rawAttributes, dialect: label, pk: 'id' })
1600
+ let ids = async () => (await md.findAll({ raw: true })).map((v) => v.id).sort()
1601
+ let hit = async (find) => (await md.findAll({ where: cvFind(find, ctx()), raw: true })).map((v) => v.id).sort()
1602
+ //errOf, 執行fn並回傳其拋出之訊息(未拋錯為null); cvFind於轉譯階段同步拋錯, 故須包在函數內
1603
+ let errOf = async (fn) => {
1604
+ try {
1605
+ await fn()
1606
+ return null
1607
+ }
1608
+ catch (e) {
1609
+ return String(e.message)
1610
+ }
1611
+ }
1612
+
1613
+ before(async function() {
1614
+ let o = getOpt()
1615
+ if (label === 'sqlite') {
1616
+ sq = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
1617
+ }
1618
+ else {
1619
+ let u = new URL(o.url)
1620
+ sq = new Sequelize(o.db, decodeURIComponent(u.username), decodeURIComponent(u.password), { dialect: 'mssql', host: u.hostname, port: Number(u.port), logging: false })
1621
+ }
1622
+ md = sq.define('talias', {
1623
+ id: { type: Sequelize.STRING, primaryKey: true },
1624
+ ageNum: { type: Sequelize.INTEGER, field: 'age_num' },
1625
+ title: { type: Sequelize.TEXT, field: 'physical_title' },
1626
+ at: { type: Sequelize.DATE, field: 'at_col' },
1627
+ ra: { type: Sequelize.DATE, field: 'rb' }, //欄名rb, 恰為下一屬性之名稱
1628
+ rb: { type: Sequelize.INTEGER, field: 'rc' },
1629
+ }, { tableName: 'talias', timestamps: false })
1630
+ await md.sync({ force: true })
1631
+ })
1632
+
1633
+ after(async function() {
1634
+ if (md) {
1635
+ await md.drop()
1636
+ }
1637
+ if (sq) {
1638
+ await sq.close()
1639
+ }
1640
+ })
1641
+
1642
+ let seed = async () => {
1643
+ await md.destroy({ where: {} })
1644
+ await md.bulkCreate([
1645
+ { id: 'r1', ageNum: 5, title: 'rose_mary', at: new Date('2019-01-01T00:00:00Z'), ra: new Date('2019-01-01T00:00:00Z'), rb: 5 },
1646
+ { id: 'r2', ageNum: 6, title: 'Peter', at: new Date('2026-06-15T00:00:00Z'), ra: new Date('2026-06-15T00:00:00Z'), rb: 6 },
1647
+ { id: 'r3', ageNum: 7, title: '100%', at: new Date('2026-06-01T00:00:00Z'), ra: new Date('2026-06-01T00:00:00Z'), rb: 7 },
1648
+ { id: 'r4' },
1649
+ ])
1650
+ }
1651
+
1652
+ //[說明, 屬性名, 資料庫欄名, 值, 預期命中之id]; 預期命中由seed()之數據獨立推得(非取自同一實作之屬性名鍵命中, run05 sol F08、opus [8]):
1653
+ //ageNum: r1=5 r2=6 r3=7 r4=NULL; title: r1='rose_mary' r2='Peter' r3='100%' r4=NULL; at: r1=2019-01-01 r2=2026-06-15 r3=2026-06-01 r4=NULL
1654
+ let alias = [
1655
+ ['INTEGER 等值', 'ageNum', 'age_num', 5, ['r1']],
1656
+ ['INTEGER 等值(另一列)', 'ageNum', 'age_num', 6, ['r2']],
1657
+ ['INTEGER $gt', 'ageNum', 'age_num', { $gt: 5 }, ['r2', 'r3']],
1658
+ ['INTEGER 數值字串', 'ageNum', 'age_num', '7', ['r3']],
1659
+ ['INTEGER 空$nin', 'ageNum', 'age_num', { $nin: [] }, ['r1', 'r2', 'r3', 'r4']], //A2: 空集合$nin為恆真(含NULL列)
1660
+ ['INTEGER 空$in', 'ageNum', 'age_num', { $in: [] }, []],
1661
+ ['TEXT 等值', 'title', 'physical_title', 'Peter', ['r2']],
1662
+ ['TEXT $regex', 'title', 'physical_title', { $regex: '_' }, ['r1']], //底線為字面
1663
+ ['TEXT $regex i', 'title', 'physical_title', { $regex: 'E', $options: 'i' }, ['r1', 'r2']],
1664
+ ['DATE $lt ISO字串', 'at', 'at_col', { $lt: '2026-06-01T00:00:00Z' }, ['r1']],
1665
+ ['DATE $gt epoch', 'at', 'at_col', { $gt: Date.parse('2020-01-01T00:00:00Z') }, ['r2', 'r3']],
1666
+ ['DATE $in 混合', 'at', 'at_col', { $in: [new Date('2019-01-01T00:00:00Z'), '2026-06-15T00:00:00Z', Date.parse('2026-06-02T00:00:00Z')] }, ['r1', 'r2']],
1667
+ ['DATE 等值Date', 'at', 'at_col', new Date('2019-01-01T00:00:00Z'), ['r1']],
1668
+ ['DATE null', 'at', 'at_col', null, ['r4']],
1669
+ ]
1670
+ for (let [t, prop, col, val, exp] of alias) {
1671
+ it(`資料庫欄名 ≡ 屬性名, select命中 = delAll刪除: ${t}`, async function() {
1672
+ await seed()
1673
+ let a = await hit({ [prop]: val })
1674
+ assert.deepStrictEqual(a, exp, 'select: 屬性名鍵之命中須等於由seed數據獨立推得者')
1675
+ let b = await hit({ [col]: val })
1676
+ assert.deepStrictEqual(b, exp, 'select: 欄名鍵之命中須等於由seed數據獨立推得者')
1677
+ await md.destroy({ where: cvFind({ [col]: val }, ctx()) })
1678
+ let left = await ids()
1679
+ assert.deepStrictEqual(ALL.filter((v) => left.indexOf(v) < 0), exp, 'delAll: 刪除者須等於由seed數據獨立推得者')
1680
+ })
1681
+ }
1682
+
1683
+ //未給attrs(深引用者可不給; 鍵不解析而交資料庫, R4)之空集合各入口經資料庫實跑: 原badsNoCol段於嚴格鍵改制後移除, 此處保留其資料庫實跑之證據(run05 opus [9])
1684
+ //屬性名鍵仍由sequelize映射為資料庫欄名; 不存在之欄須由資料庫回報而不得清空或刪除
1685
+ let ctx0 = () => ({ escape: (v) => sq.escape(v), dialect: label })
1686
+ let hit0 = async (find) => (await md.findAll({ where: cvFind(find, ctx0()), raw: true })).map((v) => v.id).sort()
1687
+ it('未給attrs: 空集合$in/$nin/陣列值於資料庫實跑之命中(A2), 欄名經sequelize映射', async function() {
1688
+ await seed()
1689
+ assert.deepStrictEqual(await hit0({ ageNum: { $in: [] } }), [])
1690
+ assert.deepStrictEqual(await hit0({ ageNum: [] }), [])
1691
+ assert.deepStrictEqual(await hit0({ ageNum: { $nin: [] } }), ALL)
1692
+ assert.deepStrictEqual(await hit0({ ageNum: { $not: { $in: [] } } }), ALL)
1693
+ assert.deepStrictEqual(await hit0({ age_num: { $nin: [] } }), ALL)
1694
+ assert.deepStrictEqual(await hit0({ ageNum: 5 }), ['r1'])
1695
+ })
1696
+ for (let [t, find] of [
1697
+ ['$nin:[]', { nocol: { $nin: [] } }],
1698
+ ['$in:[]', { nocol: { $in: [] } }],
1699
+ ['陣列值[]', { nocol: [] }],
1700
+ ['$not:{$in:[]}', { nocol: { $not: { $in: [] } } }],
1701
+ ['$or含空$nin', { $or: [{ nocol: { $nin: [] } }, { id: 'zz' }] }],
1702
+ ]) {
1703
+ it(`未給attrs: 不存在之欄 × 空集合由資料庫回報而reject, 資料不變: ${t}`, async function() {
1704
+ await seed()
1705
+ let rs = await errOf(() => md.findAll({ where: cvFind(find, ctx0()), raw: true }))
1706
+ let rd = await errOf(() => md.destroy({ where: cvFind(find, ctx0()) }))
1707
+ assert.ok(rs !== null, 'select未reject')
1708
+ assert.ok(rd !== null, 'delAll未reject')
1709
+ assert.deepStrictEqual(await ids(), ALL)
1710
+ })
1711
+ }
1712
+
1713
+ //型別不符須於轉譯階段reject: 原sqlite下 age_num < 'abc' 恆真而delAll刪除全部非NULL列
1714
+ let bads = [
1715
+ ['整數欄之欄名 × 非數值字串', { age_num: { $lt: 'abc' } }, '[int]'],
1716
+ ['整數欄之欄名大小寫變體(不解析)', { AGE_NUM: 'abc' }, 'unknown column name [AGE_NUM]'],
1717
+ ['DATE欄之欄名 × 非日期字串', { at_col: { $lt: 'garbage' } }, '[date]'],
1718
+ ['整數欄之欄名 × $regex', { age_num: { $regex: '1' } }, 'requires a string column'],
1719
+ ['DATE欄之欄名大小寫變體 × 合法epoch', { AT_COL: { $gt: Date.parse('2030-01-01T00:00:00Z') } }, 'unknown column name [AT_COL]'],
1720
+ ['屬性rb(欄rc) × 非數值字串', { rb: { $lt: 'abc' } }, '[int]'],
1721
+ ['欄名rc × 非數值字串', { rc: { $lt: 'abc' } }, '[int]'],
1722
+ ]
1723
+ for (let [t, find, msg] of bads) {
1724
+ it(`型別不符reject且資料不變: ${t}`, async function() {
1725
+ await seed()
1726
+ let rs = await errOf(() => md.findAll({ where: cvFind(find, ctx()), raw: true }))
1727
+ let rd = await errOf(() => md.destroy({ where: cvFind(find, ctx()) }))
1728
+ assert.ok(rs !== null && rs.indexOf(msg) >= 0, `select: ${rs}`)
1729
+ assert.ok(rd !== null && rd.indexOf(msg) >= 0, `delAll: ${rd}`)
1730
+ assert.deepStrictEqual(await ids(), ALL)
1731
+ })
1732
+ }
1733
+
1734
+ //撞名: 屬性ra(DATE, 欄rb)之值若照常轉Date送出, sequelize把鍵ra映射為rb後取到屬性rb(INTEGER)之型別, 以NUMBER序列化而成Date.toString文字,
1735
+ //sqlite以文字比較而命中並刪除全部非NULL之rb列(HEAD對直接給Date者即如此); 反查所得非本屬性者須reject(不論型別類別)
1736
+ let clash = [
1737
+ ['DATE $lt ISO字串', { ra: { $lt: '2026-01-01T00:00:00Z' } }],
1738
+ ['DATE 等值Date', { ra: new Date('2019-01-01T00:00:00Z') }],
1739
+ ['DATE $gt epoch', { ra: { $gt: Date.parse('2030-01-01T00:00:00Z') } }],
1740
+ ['DATE $in', { ra: { $in: ['2019-01-01T00:00:00Z'] } }],
1741
+ ['DATE $nin', { ra: { $nin: [new Date('2019-01-01T00:00:00Z')] } }],
1742
+ ]
1743
+ for (let [t, find] of clash) {
1744
+ it(`屬性之欄名恰為他屬性之名稱而型別類別不同: 兩函數皆reject且資料不變: ${t}`, async function() {
1745
+ await seed()
1746
+ let rs = await errOf(() => md.findAll({ where: cvFind(find, ctx()), raw: true }))
1747
+ let rd = await errOf(() => md.destroy({ where: cvFind(find, ctx()) }))
1748
+ assert.ok(rs !== null && rs.indexOf('ambiguous column name [ra]') >= 0, `select: ${rs}`)
1749
+ assert.ok(rd !== null && rd.indexOf('ambiguous column name [ra]') >= 0, `delAll: ${rd}`)
1750
+ assert.deepStrictEqual(await ids(), ALL)
1751
+ })
1752
+ }
1753
+
1754
+ it('撞名之他屬性(rb, 欄rc)不受影響: 屬性名鍵、欄名鍵之命中一致', async function() {
1755
+ await seed()
1756
+ let a = await hit({ rb: { $gt: 5 } })
1757
+ assert.deepStrictEqual(a, ['r2', 'r3'])
1758
+ assert.deepStrictEqual(await hit({ rc: { $gt: 5 } }), a)
1759
+ await md.destroy({ where: cvFind({ rc: { $gt: 5 } }, ctx()) })
1760
+ assert.deepStrictEqual(await ids(), ['r1', 'r4'])
1761
+ })
1762
+
1763
+ })
1764
+
1765
+
1766
+ describe(`${label} 欄名邊界`, function() {
1767
+
1768
+ //run05 sol F01/F02、opus [1][2][4]、自查(規格句 R4欄名解析(b)(c), 系列最低要求①②③): 欄名恰為sequelize於轉成SQL前重新解讀之形態, 於資料庫實跑——
1769
+ //(1)映射後之欄名為空字串: sync以field||name建欄(欄名為屬性名)而mapWhereFieldNames把鍵映射為'', _joinKeyValue遇空鍵只回值, 條件成常數(WHERE (5)), delAll刪全表;
1770
+ //(2)首尾皆$且中間有換行: isColString只比首尾字元, 比對的是去掉$之另一欄; $regex加i時LOWER(col)卻引用完整欄名, 同一find因旗標而命中不同列;
1771
+ //(3)共用一欄: 欄名恰為先定義者之名稱時, sequelize依先定義者之型別序列化, 實體欄之型別卻由最後定義者決定
1772
+ let sq = null
1773
+ let mdE = null
1774
+ let mdV = null
1775
+ let mdS = null
1776
+ let ctx = (md) => ({ escape: (v) => sq.escape(v), attrs: md.rawAttributes, dialect: label, pk: 'id' })
1777
+ let ids = async (md) => (await md.findAll({ raw: true, attributes: ['id'] })).map((v) => v.id).sort()
1778
+ let errOf = async (fn) => {
1779
+ try {
1780
+ await fn()
1781
+ return null
1782
+ }
1783
+ catch (e) {
1784
+ return String(e.message)
1785
+ }
1786
+ }
1787
+
1788
+ before(async function() {
1789
+ let o = getOpt()
1790
+ if (label === 'sqlite') {
1791
+ sq = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
1792
+ }
1793
+ else {
1794
+ let u = new URL(o.url)
1795
+ sq = new Sequelize(o.db, decodeURIComponent(u.username), decodeURIComponent(u.password), { dialect: 'mssql', host: u.hostname, port: Number(u.port), logging: false })
1796
+ }
1797
+ //(1)
1798
+ mdE = sq.define('tempfd', {
1799
+ id: { type: Sequelize.STRING, primaryKey: true },
1800
+ a: { type: Sequelize.INTEGER, field: '' },
1801
+ }, { tableName: 'tempfd', timestamps: false })
1802
+ //(2)
1803
+ mdV = sq.define('tdollar', {
1804
+ id: { type: Sequelize.STRING, primaryKey: true },
1805
+ t: { type: Sequelize.STRING, field: '\nname' },
1806
+ w: { type: Sequelize.STRING, field: '$\nname$' },
1807
+ }, { tableName: 'tdollar', timestamps: false })
1808
+ //(3)
1809
+ mdS = sq.define('tshare', {
1810
+ id: { type: Sequelize.STRING, primaryKey: true },
1811
+ sa: { type: Sequelize.DATE, field: 'sa' },
1812
+ sb: { type: Sequelize.INTEGER, field: 'sa' },
1813
+ }, { tableName: 'tshare', timestamps: false })
1814
+ await mdE.sync({ force: true })
1815
+ await mdV.sync({ force: true })
1816
+ await mdS.sync({ force: true })
1817
+ })
1818
+
1819
+ after(async function() {
1820
+ for (let md of [mdE, mdV, mdS]) {
1821
+ if (md) {
1822
+ await md.drop()
1823
+ }
1824
+ }
1825
+ if (sq) {
1826
+ await sq.close()
1827
+ }
1828
+ })
1829
+
1830
+ //rej, select與delAll(以cvFind直接轉譯後交sequelize之findAll/destroy, 同WOrmReladb之路徑)皆須於轉譯階段reject且資料不變
1831
+ let rej = async (md, seed, find, msg) => {
1832
+ await md.destroy({ where: {} })
1833
+ await md.bulkCreate(seed)
1834
+ let before = await ids(md)
1835
+ let rs = await errOf(() => md.findAll({ where: cvFind(find, ctx(md)), raw: true }))
1836
+ let rd = await errOf(() => md.destroy({ where: cvFind(find, ctx(md)) }))
1837
+ assert.ok(rs !== null && rs.indexOf(msg) >= 0, `select: ${rs}`)
1838
+ assert.ok(rd !== null && rd.indexOf(msg) >= 0, `delAll: ${rd}`)
1839
+ assert.deepStrictEqual(await ids(md), before, '資料須不變')
1840
+ }
1841
+
1842
+ for (let [t, find] of [
1843
+ ['等值', { a: 5 }],
1844
+ ['$gt', { a: { $gt: 100 } }],
1845
+ ['$in', { a: { $in: [5] } }],
1846
+ ['空$nin', { a: { $nin: [] } }],
1847
+ ['$exists', { a: { $exists: true } }],
1848
+ ]) {
1849
+ it(`映射後之欄名為空字串: 兩函數皆reject且資料不變: ${t}`, async function() {
1850
+ //mssql之sync會把rawAttributes.a.field由''正規化為'a'(2026-10-16實測), 該model於mssql無此形態; sqlite不正規化(實測 WHERE (5) 刪全表), 故僅sqlite實跑, 兩方言之轉譯階段拒收由單元測試涵蓋
1851
+ if (label !== 'sqlite') {
1852
+ this.skip()
1853
+ }
1854
+ await rej(mdE, [{ id: 'r1', a: 0 }, { id: 'r2', a: 5 }, { id: 'r3' }], find, 'unsupported column name []')
1855
+ })
1856
+ }
1857
+
1858
+ for (let [t, find] of [
1859
+ ['等值', { w: 'needle' }],
1860
+ ['$in', { w: { $in: ['needle'] } }],
1861
+ ['$regex', { w: { $regex: 'needle' } }],
1862
+ ['$regex + i(原LOWER引用完整欄名而命中另一列)', { w: { $regex: 'needle', $options: 'i' } }],
1863
+ ]) {
1864
+ it(`首尾皆$且中間有換行之欄名: 兩函數皆reject且資料不變: ${t}`, async function() {
1865
+ await rej(mdV, [{ id: 'r1', t: 'needle', w: 'x' }, { id: 'r2', t: 'x', w: 'needle' }, { id: 'r3' }], find, 'unsupported column name [$\nname$]')
1866
+ })
1867
+ }
1868
+
1869
+ for (let [t, find] of [
1870
+ ['先定義者 × 日期字串', { sa: { $lt: '2030-01-01T00:00:00Z' } }],
1871
+ ['先定義者 × epoch', { sa: { $gt: 1893456000000 } }],
1872
+ ['先定義者 × Date', { sa: new Date(0) }],
1873
+ ['先定義者 × 空$nin', { sa: { $nin: [] } }],
1874
+ ]) {
1875
+ it(`共用一欄且欄名恰為先定義者之名稱: 兩函數皆reject且資料不變: ${t}`, async function() {
1876
+ await rej(mdS, [{ id: 'r1', sb: 1 }, { id: 'r2', sb: 2 }, { id: 'r3' }], find, 'ambiguous column name [sa]')
1877
+ })
1878
+ }
1879
+
1880
+ //後定義者sb之欄名sa恰為先定義者之名稱: sequelize以rawAttributes.sa(DATE)序列化sb之值, 同樣不可信, 亦reject(單元測試涵蓋共用非屬性名之欄時最後定義者照常)
1881
+ it('共用一欄且欄名恰為先定義者之名稱: 後定義者亦reject(sequelize依先定義者之型別序列化其值), 資料不變', async function() {
1882
+ await rej(mdS, [{ id: 'r1', sb: 1 }, { id: 'r2', sb: 2 }, { id: 'r3' }], { sb: { $lt: 2 } }, 'ambiguous column name [sb]')
1883
+ })
1884
+
1885
+ })
1886
+
1362
1887
 
1363
1888
  }