w-orm-reladb 1.1.12 → 1.1.14
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/README.md +84 -1
- package/dist/w-orm-reladb.umd.js +2 -2
- package/dist/w-orm-reladb.umd.js.map +1 -1
- package/docs/WOrmReladb.html +35 -19
- package/docs/WOrmReladb.mjs.html +190 -61
- package/docs/cvFind.mjs.html +173 -7
- package/docs/global.html +560 -11
- package/docs/importModels.mjs.html +2 -2
- package/docs/index.html +2 -2
- package/models/tfloat.js +20 -0
- package/models/tlower.js +38 -0
- package/models/twf.js +20 -0
- package/models/twfk.js +20 -0
- package/package.json +2 -2
- package/src/WOrmReladb.mjs +188 -59
- package/src/cvFind.mjs +171 -5
- package/test/api-dist.test.mjs +28 -0
- package/test/api-float.mjs +262 -0
- package/test/api-lower-cases.mjs +129 -0
- package/test/api-mssql.test.mjs +47 -7
- package/test/api-scope.mjs +208 -0
- package/test/api-sqlite.test.mjs +8 -0
- package/test/api-wellformed.mjs +645 -0
- package/test/tools/floatTools.mjs +117 -0
- package/test/unit-cvfind.test.mjs +23 -2
- package/test/unit-float.test.mjs +492 -0
- package/test/unit-wellformed.test.mjs +322 -0
package/src/cvFind.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import Sequelize from 'sequelize'
|
|
2
|
+
import get from 'lodash-es/get.js'
|
|
2
3
|
import cvOrmFind from 'wsemi/src/cvOrmFind.mjs'
|
|
4
|
+
import chkOrmStr from 'wsemi/src/chkOrmStr.mjs'
|
|
5
|
+
import chkOrmData from 'wsemi/src/chkOrmData.mjs'
|
|
3
6
|
|
|
4
7
|
|
|
5
8
|
//查詢條件(find)之轉譯, 供select與delAll共用, 令同一find於兩函數由同一SQL謂詞決定命中
|
|
@@ -13,6 +16,7 @@ import cvOrmFind from 'wsemi/src/cvOrmFind.mjs'
|
|
|
13
16
|
//不得供破壞性之delAll使用, 1.1.7起改自寫白名單; 第33項批1起形狀層改由cvOrmFind單一擁有, 本模組刪除Symbol/Op全集、$not $nor $regex $options $exists、
|
|
14
17
|
//RegExp值、陣列值即$in、非物件視為{}等形狀層(破壞性變更見CLAUDE_rulebook.md與README)
|
|
15
18
|
//主鍵值之正規化(normPk)亦屬本模組, 供主鍵函數與主鍵欄條件共用, 規則見R8
|
|
19
|
+
//寫入側之字串良構檢核(chkPkWf、chkWf, 規則見R13)亦屬本模組: 字串之判定單一出處為wsemi之chkOrmStr、chkOrmData, 本模組只負責接線與訊息組裝
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
//Op
|
|
@@ -111,6 +115,90 @@ function parseNoTzLocal(s) {
|
|
|
111
115
|
}
|
|
112
116
|
|
|
113
117
|
|
|
118
|
+
/**
|
|
119
|
+
* 以UTC分量建構Date(牆上時間分量作為UTC瞬間, 即浮動時間), 曆法上不存在或年不在1–9999者回傳null
|
|
120
|
+
* 註: 以setUTCFullYear設定完整年份(Date.UTC把0–99年映射為1900–1999); 逐欄回驗, 如2026-02-30、24:00:00、60秒皆為null; 與主機時區無關(夏令跳過之時刻在浮動空間亦合法)
|
|
121
|
+
*
|
|
122
|
+
* @ignore
|
|
123
|
+
*/
|
|
124
|
+
function utcOfParts(y, mo, d, h, mi, sec, ms) {
|
|
125
|
+
let t = new Date(0)
|
|
126
|
+
t.setUTCFullYear(y, mo - 1, d)
|
|
127
|
+
t.setUTCHours(h, mi, sec, ms)
|
|
128
|
+
if (t.getUTCFullYear() !== y || t.getUTCMonth() !== mo - 1 || t.getUTCDate() !== d || t.getUTCHours() !== h || t.getUTCMinutes() !== mi || t.getUTCSeconds() !== sec || t.getUTCMilliseconds() !== ms) {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
return inYearRange(y) ? t : null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
//內建Date.prototype之getTime與本地分量getter, 供toFloating讀Date之內部時間與本地分量(載入時取得, 以call呼叫, 不受後續對原型或物件之覆寫影響)
|
|
136
|
+
let dateGetTime = Date.prototype.getTime
|
|
137
|
+
let dateGetYear = Date.prototype.getFullYear
|
|
138
|
+
let dateGetMonth = Date.prototype.getMonth
|
|
139
|
+
let dateGetDate = Date.prototype.getDate
|
|
140
|
+
let dateGetHours = Date.prototype.getHours
|
|
141
|
+
let dateGetMinutes = Date.prototype.getMinutes
|
|
142
|
+
let dateGetSeconds = Date.prototype.getSeconds
|
|
143
|
+
let dateGetMs = Date.prototype.getMilliseconds
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* toFloating之本體, 可能因敵意輸入(如getPrototypeOf陷阱拋錯之Proxy)而拋錯, 由toFloating統一轉為null
|
|
148
|
+
*
|
|
149
|
+
* @ignore
|
|
150
|
+
*/
|
|
151
|
+
function floatOf(v) {
|
|
152
|
+
let inst = null
|
|
153
|
+
if (typeof v === 'string') {
|
|
154
|
+
let m = v.match(reIsoNoTz)
|
|
155
|
+
if (m !== null) {
|
|
156
|
+
let ms = m[7] === undefined ? 0 : Number(`${m[7]}00`.slice(0, 3))
|
|
157
|
+
return utcOfParts(Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4]), Number(m[5]), m[6] === undefined ? 0 : Number(m[6]), ms)
|
|
158
|
+
}
|
|
159
|
+
let mt = v.match(reIsoTz)
|
|
160
|
+
if (mt === null || !isCalendarDate(mt[1], mt[2], mt[3])) {
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
inst = new Date(Date.parse(v.replace(' ', 'T')))
|
|
164
|
+
}
|
|
165
|
+
else if (typeof v === 'number') {
|
|
166
|
+
inst = new Date(v)
|
|
167
|
+
}
|
|
168
|
+
else if (typeof v === 'object' && v !== null) {
|
|
169
|
+
//Date以內建getTime讀其內部時間(跨realm之Date亦可; 非Date拋TypeError, 由toFloating轉為null), 並建普通Date複本取本地分量,
|
|
170
|
+
//不使用物件自有或原型上可被覆寫之getTime與本地getter(與條件側經cvOrmFind複製Date同, 兩側認定一致)
|
|
171
|
+
inst = new Date(dateGetTime.call(v))
|
|
172
|
+
}
|
|
173
|
+
if (inst === null || isNaN(dateGetTime.call(inst))) {
|
|
174
|
+
return null
|
|
175
|
+
}
|
|
176
|
+
return utcOfParts(dateGetYear.call(inst), dateGetMonth.call(inst) + 1, dateGetDate.call(inst), dateGetHours.call(inst), dateGetMinutes.call(inst), dateGetSeconds.call(inst), dateGetMs.call(inst))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 將DATE欄條件值換算為浮動時間(牆上時間分量作為UTC瞬間), 不合法者回傳null; 供ctx.dateNoTz為'floating'時條件側使用, 亦供呼叫端將後端回傳之牆上時間建為記憶體表之DATE值
|
|
182
|
+
* 註: 無時區字串(文法同'local': YYYY-MM-DD[T或單一空白]HH:mm[:ss[.小數]], 純日期不收)直接取其分量, 不經主機時區;
|
|
183
|
+
* Date、epoch與帶時區之ISO字串先得瞬間, 再取其於主機時區之本地分量(Jet等後端將本地分量視為牆上時間), 故new Date(2026,0,1)(本地午夜)恆為2026-01-01T00:00:00Z之浮動值而與主機時區無關
|
|
184
|
+
* 註: 契約(CLAUDE_rulebook.md之R14): ①編碼後之浮動Date/epoch不可再當'floating'條件輸入(Date一律被當作瞬間取本地分量, 再次輸入即二次位移);
|
|
185
|
+
* ②回傳null者呼叫端須整批拒收, 不得以原值或NULL代入(NULL使該列於範圍條件不命中、IS NULL命中, 與要修之誤刪同型);
|
|
186
|
+
* ③條件側走'floating'與數據側經本函數須同進同退, 混用之後果為靜默誤命中(資料遺失類)
|
|
187
|
+
* 註: 不拋錯(不合法者一律回傳null); 小數秒取前三位(截斷至毫秒); 牆上年須在1–9999
|
|
188
|
+
*
|
|
189
|
+
* @param {Date|Number|String} v 輸入Date、epoch數值或字串
|
|
190
|
+
* @returns {Date|null} 回傳浮動時間之Date, 不合法者回傳null
|
|
191
|
+
*/
|
|
192
|
+
function toFloating(v) {
|
|
193
|
+
try {
|
|
194
|
+
return floatOf(v)
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
return null
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
114
202
|
/**
|
|
115
203
|
* 判定年月日是否為曆法上存在之日期, 如2026-02-30不存在; 年須在1以上(年0000不收)
|
|
116
204
|
* 註: 不用Date.UTC(y,..)建構, 因其把0–99年映射為1900–1999而使年0001–0099之字串被誤拒; 以setUTCFullYear設定完整年份
|
|
@@ -560,6 +648,8 @@ function unwrap(v) {
|
|
|
560
648
|
* DECIMAL主鍵欄之值小數位多於scale者寫入時會被四捨五入而改變鍵(mssql之DECIMAL(10,2)存1.254為1.25, insert回報已存在而selectByPk(1.254)回null), 視為型別不符
|
|
561
649
|
* 註: 其他型別之主鍵欄不檢型別, 收非空字串與有限數值
|
|
562
650
|
* 註: NaN、±Infinity、空字串、布林、物件、陣列、Date、BigInt、null、undefined為無效
|
|
651
|
+
* 註: 含孤立代理碼元或NUL之字串為無效(第66項, R13; 與條件側同標準): UTF-8存放之資料庫會把孤立代理碼元改寫為U+FFFD而使不同主鍵寫成同一鍵,
|
|
652
|
+
* 回傳形狀不變({kind:'invalid'}), 故沿用各呼叫端對無效主鍵之既有處置; 需區辨[未給]與[不合格字串]者(autoGenPk不得以自動產生之主鍵取代呼叫端所給之不合格字串)用chkPkWf
|
|
563
653
|
*
|
|
564
654
|
* @param {*} v 輸入主鍵值
|
|
565
655
|
* @param {String} kind 輸入主鍵欄之型別分類, 取自getColKind
|
|
@@ -576,6 +666,9 @@ function normPk(v, kind, scale = null) {
|
|
|
576
666
|
if (v === '') {
|
|
577
667
|
return { kind: 'invalid' }
|
|
578
668
|
}
|
|
669
|
+
if (chkOrmStr(v) !== null) {
|
|
670
|
+
return { kind: 'invalid' }
|
|
671
|
+
}
|
|
579
672
|
if (kind === 'int') {
|
|
580
673
|
if (!reIntStd.test(v)) {
|
|
581
674
|
return { kind: 'mismatch' }
|
|
@@ -615,6 +708,70 @@ function normPk(v, kind, scale = null) {
|
|
|
615
708
|
return { kind: 'invalid' }
|
|
616
709
|
}
|
|
617
710
|
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* 檢核主鍵值之字串良構: 字串(含new String之原始值)不得含孤立代理碼元或NUL(第66項, R13)
|
|
714
|
+
*
|
|
715
|
+
* 註: 判定單一出處為wsemi之chkOrmStr(預設連NUL一併檢查, 主鍵與條件側同標準); 非字串回傳null(其有效性由normPk判定)
|
|
716
|
+
* 註: 先依normPk同一規則拆包裝物件(new String), 令其與原始字串同處置
|
|
717
|
+
*
|
|
718
|
+
* @param {*} v 輸入主鍵值
|
|
719
|
+
* @returns {String|null} 回傳null(非字串或合格),或固定原因字串'string contains lone surrogate'、'string contains NUL'
|
|
720
|
+
*/
|
|
721
|
+
function chkPkWf(v) {
|
|
722
|
+
v = unwrap(v)
|
|
723
|
+
if (typeof v !== 'string') {
|
|
724
|
+
return null
|
|
725
|
+
}
|
|
726
|
+
return chkOrmStr(v)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* 逐筆檢核寫入數據之字串良構(第66項, R13), 回傳與輸入等長之訊息陣列
|
|
732
|
+
*
|
|
733
|
+
* 註: 輸入須為呼叫端數據之cloneDeep複本(檢查之值即使用之值): 本函數對各筆先檢主鍵再(withData為true時)檢資料, 同筆內主鍵先於資料,
|
|
734
|
+
* 資料由wsemi之chkOrmData走訪(鍵名先於值, 含巢狀物件、陣列元素、Map/Set等實存內容; 不論欄位是否在model內, 檢核先於欄位過濾)
|
|
735
|
+
* 註: 訊息為固定詞彙且不回顯值: invalid id: <原因> at [data[<索引>].<主鍵欄>]、invalid data: <原因> at [<路徑>]; 合格者為null
|
|
736
|
+
* 註: 主鍵值為包裝物件(new String、new Number)且合格者, 將原始值(字串或數值)寫回複本, 令其後之正規化讀到的即本函數所檢查者(包裝物件之valueOf只求值一次)
|
|
737
|
+
* 註: 資料之NUL不檢查(值之NUL不拒, 僅主鍵拒NUL)
|
|
738
|
+
*
|
|
739
|
+
* @param {Array} data 輸入數據陣列(已cloneDeep)
|
|
740
|
+
* @param {String} pk 輸入主鍵欄位名
|
|
741
|
+
* @param {Boolean} withData 輸入是否一併檢核資料(false只檢主鍵, 供del)
|
|
742
|
+
* @returns {Array} 回傳訊息陣列,合格者為null
|
|
743
|
+
*/
|
|
744
|
+
function chkWf(data, pk, withData) {
|
|
745
|
+
let msgs = []
|
|
746
|
+
for (let k = 0; k < data.length; k++) {
|
|
747
|
+
let v = data[k]
|
|
748
|
+
let msg = null
|
|
749
|
+
|
|
750
|
+
//pk, 主鍵先於資料; 包裝物件之valueOf只讀取一次(u為原始值), 合格者寫回複本
|
|
751
|
+
let pv = get(v, pk)
|
|
752
|
+
let u = unwrap(pv)
|
|
753
|
+
let why = chkPkWf(u)
|
|
754
|
+
if (why !== null) {
|
|
755
|
+
msg = `invalid id: ${why} at [data[${k}].${pk}]`
|
|
756
|
+
}
|
|
757
|
+
else if (u !== pv && (typeof u === 'string' || typeof u === 'number') && v !== null && typeof v === 'object') {
|
|
758
|
+
v[pk] = u
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
//data
|
|
762
|
+
if (msg === null && withData) {
|
|
763
|
+
let r = chkOrmData(v, { root: `data[${k}]` })
|
|
764
|
+
if (r !== null) {
|
|
765
|
+
msg = `invalid data: ${r.reason} at [${r.path}]`
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
msgs.push(msg)
|
|
770
|
+
}
|
|
771
|
+
return msgs
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
|
|
618
775
|
/**
|
|
619
776
|
* 檢核純量值: 字串、有限數值、布林、有效Date, 以及null(是否允許null已由cvOrmFind依運算子判定); 並須符合欄位型別分類
|
|
620
777
|
* 註: 物件、陣列等不得交sequelize之型別stringify, 否則FLOAT成'NaN'、INTEGER成'[object Object]'、DATE成本地今日00:00而誤刪;
|
|
@@ -622,6 +779,7 @@ function normPk(v, kind, scale = null) {
|
|
|
622
779
|
* 註: 主鍵欄(pk為true)之字串類、整數類與其他數值類以normPk正規化後回傳(如字串類主鍵欄之123即'123'), 無效或型別不符者拒收,
|
|
623
780
|
* 令select與delAll對主鍵之認定與selectByPk、del等主鍵函數一致; 非主鍵欄不轉型
|
|
624
781
|
* 註: kind為date且呼叫端以ctx.dateNoTz明示'local'(dateNoTz為'local')時, 無時區日期時間字串依主機時區轉為Date回傳(見parseNoTzLocal), 其餘值照舊判定
|
|
782
|
+
* 註: kind為date且dateNoTz為'floating'時, 字串、Date、epoch一律經toFloating換算為浮動時間之Date回傳(見toFloating), 不符者拒收(R14)
|
|
625
783
|
*
|
|
626
784
|
* @ignore
|
|
627
785
|
*/
|
|
@@ -649,6 +807,14 @@ function chkScalar(v, path, kind = 'any', scale = null, pk = false, dateNoTz = u
|
|
|
649
807
|
}
|
|
650
808
|
err(`value [${String(v)}] does not match column type [${kind}]${scale !== null ? ` with scale [${scale}]` : ''}`, path)
|
|
651
809
|
}
|
|
810
|
+
if (kind === 'date' && dateNoTz === 'floating') {
|
|
811
|
+
//浮動時間(R14): 所有形式皆換算為牆上時間分量再以UTC分量建構(見toFloating), 與主機時區之UTC偏移無關; 不符者拒收
|
|
812
|
+
let t = toFloating(v)
|
|
813
|
+
if (t === null) {
|
|
814
|
+
err(`value [${String(v)}] does not match column type [${kind}]`, path)
|
|
815
|
+
}
|
|
816
|
+
return t
|
|
817
|
+
}
|
|
652
818
|
if (kind === 'date' && dateNoTz === 'local' && typeof v === 'string') {
|
|
653
819
|
//呼叫端明示之無時區日期時間字串: 依主機時區解析並以Date送出, 與直接給Date同義; 不符者續由isKindOk判定(帶時區者照舊)
|
|
654
820
|
let t = parseNoTzLocal(v)
|
|
@@ -845,14 +1011,14 @@ function chkFindShape(find) {
|
|
|
845
1011
|
* @param {Object} [ctx.attrs] 輸入執行where之model之rawAttributes, 供欄名解析(嚴格鍵: 鍵須與屬性名或資料庫欄名完全相符, 其餘拒收)、依欄位型別檢核純量與取得實際欄名; 未給時不解析鍵、不檢型別而交資料庫(見CLAUDE_rulebook.md之R4)
|
|
846
1012
|
* @param {String} [ctx.dialect] 已不使用(原供判定sqlite之LOWER僅折疊ASCII), 保留接受以相容既有呼叫端
|
|
847
1013
|
* @param {String} [ctx.pk] 輸入主鍵欄位名, 該欄之純量條件值依normPk正規化(如字串類主鍵欄之123即'123'), 未給時不正規化
|
|
848
|
-
* @param {String} [ctx.dateNoTz] 輸入無時區日期時間字串之處置, 僅接受undefined(預設, 無時區者拒收)
|
|
1014
|
+
* @param {String} [ctx.dateNoTz] 輸入無時區日期時間字串之處置, 僅接受undefined(預設, 無時區者拒收)、'local'(依主機時區解析, 與以本地時間分量建構之Date同義)或'floating'(浮動時間: 牆上時間分量作為UTC瞬間, 見toFloating), 其餘值拋錯; 僅作用於DATE欄, 供Jet等無時區後端之呼叫端明示啟用; 'local'於負偏移時區與夏令時區有已知缺陷(見README), 此類後端請用'floating', 且數據側須同樣經toFloating
|
|
849
1015
|
* @returns {Object} 回傳sequelize之where物件, 條件不合法時拋出錯誤
|
|
850
1016
|
*/
|
|
851
1017
|
function cvFind(find, ctx) {
|
|
852
1018
|
|
|
853
|
-
//check, ctx.dateNoTz須為undefined
|
|
854
|
-
if (ctx && ctx.dateNoTz !== undefined && ctx.dateNoTz !== 'local') {
|
|
855
|
-
throw new Error(`invalid ctx: dateNoTz must be 'local' or undefined, got [${String(ctx.dateNoTz)}]`)
|
|
1019
|
+
//check, ctx.dateNoTz須為undefined、'local'或'floating', 其餘值(含拼錯、大小寫不同)拋錯, 不靜默忽略
|
|
1020
|
+
if (ctx && ctx.dateNoTz !== undefined && ctx.dateNoTz !== 'local' && ctx.dateNoTz !== 'floating') {
|
|
1021
|
+
throw new Error(`invalid ctx: dateNoTz must be 'local', 'floating' or undefined, got [${String(ctx.dateNoTz)}]`)
|
|
856
1022
|
}
|
|
857
1023
|
|
|
858
1024
|
//step1, 形狀檢核(R11), 其後一律只使用複本
|
|
@@ -873,5 +1039,5 @@ function cvFind(find, ctx) {
|
|
|
873
1039
|
}
|
|
874
1040
|
|
|
875
1041
|
|
|
876
|
-
export { getColKind, getColScale, normPk, chkFindShape, inDateRange, genDateChecker }
|
|
1042
|
+
export { getColKind, getColScale, normPk, chkPkWf, chkWf, chkFindShape, inDateRange, genDateChecker, toFloating }
|
|
877
1043
|
export default cvFind
|
package/test/api-dist.test.mjs
CHANGED
|
@@ -95,6 +95,34 @@ describe('dist (installed layout)', function() {
|
|
|
95
95
|
assert.deepStrictEqual(errs, [])
|
|
96
96
|
})
|
|
97
97
|
|
|
98
|
+
it('字串良構(R13): 經dist使用時, 孤立代理之主鍵與資料整批reject、selectByPk回null、建構時opt含孤立代理throw, 良構字串(含emoji)照常', async function() {
|
|
99
|
+
//wsemi之chkOrmStr、chkOrmData隨dist打包(純JS), 須在打包與轉譯後仍判定正確
|
|
100
|
+
let w = wo({ url: 'sqlite://:', db: 'worm', cl: 'users', fdModels, storage: `${fd}/wf.sqlite` })
|
|
101
|
+
let errs = []
|
|
102
|
+
w.on('error', (mode, data, err) => {
|
|
103
|
+
errs.push([mode, err])
|
|
104
|
+
})
|
|
105
|
+
assert.strictEqual(await w.createStorage(), 'created')
|
|
106
|
+
|
|
107
|
+
let msgOf = (err) => String(err && err.message !== undefined ? err.message : err)
|
|
108
|
+
let r1 = await w.insert([{ id: 'ok1', name: 'A' }, { id: 'a\uD800', name: 'B' }]).then((v) => ({ v }), (err) => ({ err }))
|
|
109
|
+
assert.strictEqual(msgOf(r1.err), 'invalid id: string contains lone surrogate at [data[1].id]')
|
|
110
|
+
let r2 = await w.insertBulk([{ id: 'ok2', name: 'x\uDC00' }]).then((v) => ({ v }), (err) => ({ err }))
|
|
111
|
+
assert.strictEqual(msgOf(r2.err), 'invalid data: string contains lone surrogate at [data[0].name]')
|
|
112
|
+
let r3 = await w.insert([{ id: 'ok3', name: 'A', meta: { tags: ['a', 'b\uD800'] } }]).then((v) => ({ v }), (err) => ({ err }))
|
|
113
|
+
assert.strictEqual(msgOf(r3.err), 'invalid data: string contains lone surrogate at [data[0].meta.tags[1]]')
|
|
114
|
+
assert.deepStrictEqual(errs.map((v) => v[0]), ['insert', 'insertBulk', 'insert'])
|
|
115
|
+
assert.deepStrictEqual(await w.select(), [], '整批reject不寫入任何一筆')
|
|
116
|
+
|
|
117
|
+
let rs = await w.save([{ id: 'a\u0000b', name: 'x' }, { id: 'e😀', name: 'emoji' }])
|
|
118
|
+
assert.deepStrictEqual(rs.map((v) => v.ok), [0, 1])
|
|
119
|
+
assert.strictEqual(rs[0].err, 'invalid id: string contains NUL at [data[0].id]')
|
|
120
|
+
assert.strictEqual(await w.selectByPk('e\uD83D'), null)
|
|
121
|
+
assert.deepStrictEqual(await w.selectByPk('e😀'), { id: 'e😀', name: 'emoji', value: null })
|
|
122
|
+
|
|
123
|
+
assert.throws(() => wo({ url: 'sqlite://:', db: 'worm', cl: 'users\uD800', fdModels }), (err) => msgOf(err) === 'invalid opt.cl: string contains lone surrogate')
|
|
124
|
+
})
|
|
125
|
+
|
|
98
126
|
it('json型model(對照): createStorage、insert、select皆成功且不發error事件', async function() {
|
|
99
127
|
//rulebook R10: 兩種modelType經main皆須可用; json型不經requireModel, 修正前即可用
|
|
100
128
|
let w = wo({ url: 'sqlite://:', db: 'worm', cl: 'users', fdModels, modelType: 'json', storage: `${fd}/json.sqlite` })
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import wo from '../src/WOrmReladb.mjs'
|
|
3
|
+
import cvFind, { toFloating } from '../src/cvFind.mjs'
|
|
4
|
+
import { withTz } from './tools/withTz.mjs'
|
|
5
|
+
import { TZS, TZS_DST_EAST, OPS, WALL, CONDS, pad, condVal, condWall, wantIdx, tzSelfCheck } from './tools/floatTools.mjs'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
//本檔為mssql與sqlite共用之浮動時間測試套組(CLAUDE_rulebook.md之R14), 由api-sqlite.test.mjs與api-mssql.test.mjs引入
|
|
9
|
+
//模擬w-orm-mdb之queryMemory: 資料表之DATE欄存「牆上時間分量作為UTC瞬間」(數據列經toFloating), 條件走ctx.dateNoTz:'floating'經cvFind取得where, 由model執行findAll/destroy
|
|
10
|
+
//(WOrmReladb之getFindCtx不設定dateNoTz, 公開select/delAll不走此路徑; 數據由公開insert寫入, 取得where後由實例之model執行)
|
|
11
|
+
//預言機: 牆上時間字串以UTC分量數值比較(Jet語意: 無時區、無夏令), 與主機時區無關
|
|
12
|
+
//時區矩陣: 12個時區(含負偏移、半小時與45分偏移、+14、夏令跳過之UTC+時區), 以執行期賦值process.env.TZ切換, 每個時區先自證偏移
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
let rid = (i) => `r${pad(i)}`
|
|
16
|
+
|
|
17
|
+
let hostZones = TZS.concat(TZS_DST_EAST)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 定義共用測試套組
|
|
22
|
+
*
|
|
23
|
+
* @param {String} label 輸入後端名稱字串('sqlite'或'mssql'), 供describe標題使用
|
|
24
|
+
* @param {Function} getOpt 輸入取得建構設定之函數, 於hook內呼叫以取得當下有效之連線資訊
|
|
25
|
+
*/
|
|
26
|
+
export function defineFloatSuites(label, getOpt) {
|
|
27
|
+
|
|
28
|
+
describe(`${label} 浮動時間: dateNoTz:'floating'與toFloating`, function() {
|
|
29
|
+
|
|
30
|
+
let w = null
|
|
31
|
+
let ins = null
|
|
32
|
+
let md = null
|
|
33
|
+
let cs = null
|
|
34
|
+
|
|
35
|
+
//load, 清表後以公開insert寫入[id, d]
|
|
36
|
+
let load = async (pairs) => {
|
|
37
|
+
await md.destroy({ where: {} })
|
|
38
|
+
let r = await w.insert(pairs.map(([id, d]) => ({ id, d })), cs)
|
|
39
|
+
assert.strictEqual(r.nInserted, pairs.length)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
//whereOf, 取經cvFind轉譯之where(attrs取實際執行where之model之rawAttributes, 同mdb之queryMemory)
|
|
43
|
+
let whereOf = (find, ex = {}) => cvFind(find, { attrs: md.rawAttributes, pk: 'id', ...ex })
|
|
44
|
+
|
|
45
|
+
let idsOf = async (find, ex = {}) => {
|
|
46
|
+
let rs = await md.findAll({ where: whereOf(find, ex), attributes: ['id'], raw: true })
|
|
47
|
+
return rs.map((r) => r.id).sort()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let allIds = async () => (await md.findAll({ attributes: ['id'], raw: true })).map((r) => r.id).sort()
|
|
51
|
+
|
|
52
|
+
let msgOf = (e) => String(e && e.message !== undefined ? e.message : e)
|
|
53
|
+
|
|
54
|
+
before(async function() {
|
|
55
|
+
w = wo(getOpt({ cl: 'tfloat' }))
|
|
56
|
+
await w.createStorage()
|
|
57
|
+
ins = await w.init()
|
|
58
|
+
md = ins.mds.tfloat
|
|
59
|
+
cs = { instance: ins }
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
after(async function() {
|
|
63
|
+
await ins.close()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
//運算矩陣: 條件18個牆上時間(字串、本地分量建構之Date、其epoch) x 6運算子 = 108組, 數據20列(含哨兵9999、年0100、夏令跳過/重疊之牆上時間、NULL)
|
|
67
|
+
for (let tz of hostZones) {
|
|
68
|
+
|
|
69
|
+
it(`${tz}: 108組條件(18個牆上時間 x 6運算子)命中集合 = 牆上時間數值比較之預言機, 無錯誤無誤拒; delAll($lt 今日)不誤刪9999哨兵`, async function() {
|
|
70
|
+
this.timeout(120000)
|
|
71
|
+
await withTz(tz, async () => {
|
|
72
|
+
tzSelfCheck(tz)
|
|
73
|
+
//數據列: 牆上時間字串經toFloating(同mdb之資料列轉換; 轉換失敗須整批拒收, 此處所有值皆須可換算)
|
|
74
|
+
let pairs = WALL.map((x, i) => {
|
|
75
|
+
let v = x === null ? null : toFloating(x)
|
|
76
|
+
assert.ok(x === null || v !== null, `數據列無法換算: ${x}`)
|
|
77
|
+
return [rid(i), v]
|
|
78
|
+
})
|
|
79
|
+
await load(pairs)
|
|
80
|
+
let n = 0
|
|
81
|
+
let bads = []
|
|
82
|
+
for (let [wl, kind] of CONDS) {
|
|
83
|
+
for (let op of OPS) {
|
|
84
|
+
n += 1
|
|
85
|
+
let cw = condWall(wl, kind)
|
|
86
|
+
let want = wantIdx(op, cw).map(rid)
|
|
87
|
+
let got
|
|
88
|
+
try {
|
|
89
|
+
got = await idsOf({ d: { [op]: condVal(wl, kind) } }, { dateNoTz: 'floating' })
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
got = `REJ:${msgOf(e).slice(0, 80)}`
|
|
93
|
+
}
|
|
94
|
+
if (JSON.stringify(got) !== JSON.stringify(want)) {
|
|
95
|
+
bads.push({ wl, kind, op, got, want })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
assert.strictEqual(n, 108)
|
|
100
|
+
assert.deepStrictEqual(bads, [], `${tz}: 錯誤或誤拒 ${bads.length}/108`)
|
|
101
|
+
|
|
102
|
+
//delAll($lt 今日): 三列expired、never(9999-12-31 23:59:59)、future, 只刪expired
|
|
103
|
+
await load([['e', toFloating('2020-01-01 00:00:00')], ['n', toFloating('9999-12-31 23:59:59')], ['f', toFloating('2099-01-01 00:00:00')]])
|
|
104
|
+
await md.destroy({ where: whereOf({ d: { $lt: '2026-10-20T00:00:00' } }, { dateNoTz: 'floating' }) })
|
|
105
|
+
assert.deepStrictEqual(await allIds(), ['f', 'n'], `${tz}: delAll($lt 今日)誤刪哨兵`)
|
|
106
|
+
|
|
107
|
+
//哨兵本身作條件值: 可用, 且只命中never
|
|
108
|
+
assert.deepStrictEqual(await idsOf({ d: { $gte: '9999-12-31T23:59:59' } }, { dateNoTz: 'floating' }), ['n'])
|
|
109
|
+
assert.deepStrictEqual(await idsOf({ d: '9999-12-31T23:59:59' }, { dateNoTz: 'floating' }), ['n'])
|
|
110
|
+
assert.deepStrictEqual(await idsOf({ d: { $gt: '9999-12-31T23:59:58', $lte: '9999-12-31T23:59:59' } }, { dateNoTz: 'floating' }), ['n'])
|
|
111
|
+
//$gt哨兵無列; $lt哨兵命中其餘兩列
|
|
112
|
+
assert.deepStrictEqual(await idsOf({ d: { $gt: '9999-12-31T23:59:59' } }, { dateNoTz: 'floating' }), [])
|
|
113
|
+
assert.deepStrictEqual(await idsOf({ d: { $lt: '9999-12-31T23:59:59' } }, { dateNoTz: 'floating' }), ['f'])
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it('$in/$nin: 浮動值逐元素比對(2個時區)', async function() {
|
|
120
|
+
for (let tz of ['America/Los_Angeles', 'Asia/Taipei']) {
|
|
121
|
+
await withTz(tz, async () => {
|
|
122
|
+
await load([['a', toFloating('2026-01-01 00:00:00')], ['b', toFloating('2026-06-01 12:00:00')], ['c', toFloating('9999-12-31 23:59:59')], ['z', null]])
|
|
123
|
+
assert.deepStrictEqual(await idsOf({ d: { $in: ['2026-01-01T00:00:00', '9999-12-31T23:59:59'] } }, { dateNoTz: 'floating' }), ['a', 'c'])
|
|
124
|
+
assert.deepStrictEqual(await idsOf({ d: { $nin: ['2026-01-01T00:00:00', '9999-12-31T23:59:59'] } }, { dateNoTz: 'floating' }), ['b'])
|
|
125
|
+
assert.deepStrictEqual(await idsOf({ d: { $in: [] } }, { dateNoTz: 'floating' }), [])
|
|
126
|
+
assert.deepStrictEqual(await idsOf({ d: null }, { dateNoTz: 'floating' }), ['z'])
|
|
127
|
+
assert.deepStrictEqual(await idsOf({ d: { $ne: null } }, { dateNoTz: 'floating' }), ['a', 'b', 'c'])
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('不合法條件於轉譯階段拒收(不送SQL、資料表不變): 帶時區字串之曆法不存在、年0、5位年、亂字串、純日期(外審minimax F7)', async function() {
|
|
133
|
+
for (let tz of ['America/Los_Angeles', 'Asia/Taipei']) {
|
|
134
|
+
await withTz(tz, async () => {
|
|
135
|
+
await load([['a', toFloating('2026-01-01 00:00:00')], ['b', toFloating('2026-06-01 00:00:00')]])
|
|
136
|
+
for (let s of ['2026-02-30T00:00:00Z', '2025-02-29T00:00:00+08:00', '2026-04-31T00:00:00-08:00', '0000-06-15T00:00:00Z', '0000-06-15T00:00:00', '10000-01-01T00:00:00Z', '10000-01-01T00:00:00', 'garbage', '2026-01-01']) {
|
|
137
|
+
let e = null
|
|
138
|
+
try {
|
|
139
|
+
await idsOf({ d: { $lt: s } }, { dateNoTz: 'floating' })
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
e = msgOf(err)
|
|
143
|
+
}
|
|
144
|
+
assert.ok(e !== null && e.indexOf('does not match column type [date]') >= 0, `${tz} [${s}] 應拒收: ${e}`)
|
|
145
|
+
}
|
|
146
|
+
assert.deepStrictEqual(await allIds(), ['a', 'b'])
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
//判別力自證(僅sqlite: 年10000之文字可存入sqlite而mssql不可): 同一矩陣對'local'臂(數據列以sequelize預設new Date(字串)依主機時區入表)於負偏移時區須出現錯誤或誤拒,
|
|
152
|
+
//證明上列矩陣與預言機能偵測此缺陷(現有mdb全套測試於TZ=PST8PDT亦全綠, 對此缺陷零偵測力)
|
|
153
|
+
if (label === 'sqlite') {
|
|
154
|
+
for (let tz of ['America/Los_Angeles', 'America/New_York', 'America/Sao_Paulo', 'Etc/GMT+12']) {
|
|
155
|
+
it(`${tz}: 判別力自證——'local'臂(數據列依主機時區入表)出現錯誤或誤拒, 且delAll($lt 今日)誤刪9999哨兵`, async function() {
|
|
156
|
+
await withTz(tz, async () => {
|
|
157
|
+
await md.destroy({ where: {} })
|
|
158
|
+
await md.bulkCreate(WALL.map((x, i) => ({ id: rid(i), d: x }))) //x為字串, sequelize以new Date(字串)依主機時區解析
|
|
159
|
+
let nBad = 0
|
|
160
|
+
for (let [wl, kind] of CONDS) {
|
|
161
|
+
for (let op of OPS) {
|
|
162
|
+
let want = wantIdx(op, condWall(wl, kind)).map(rid)
|
|
163
|
+
let got
|
|
164
|
+
try {
|
|
165
|
+
got = await idsOf({ d: { [op]: condVal(wl, kind) } }, { dateNoTz: 'local' })
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
got = `REJ`
|
|
169
|
+
}
|
|
170
|
+
if (JSON.stringify(got) !== JSON.stringify(want)) {
|
|
171
|
+
nBad += 1
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
assert.ok(nBad >= 20, `${tz}: 'local'臂僅 ${nBad}/108 組出錯, 矩陣的判別力不足`)
|
|
176
|
+
//delAll($lt 今日)
|
|
177
|
+
await md.destroy({ where: {} })
|
|
178
|
+
await md.bulkCreate([['e', '2020-01-01 00:00:00'], ['n', '9999-12-31 23:59:59'], ['f', '2099-01-01 00:00:00']].map(([id, d]) => ({ id, d })))
|
|
179
|
+
await md.destroy({ where: whereOf({ d: { $lt: '2026-10-20T00:00:00' } }, { dateNoTz: 'local' }) })
|
|
180
|
+
assert.deepStrictEqual(await allIds(), ['f'], `${tz}: 'local'臂應誤刪哨兵n(此為修前缺陷, 'local'不變)`)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
//契約①: 編碼後之浮動Date(經資料庫讀回之Date)不可再當'floating'條件輸入
|
|
187
|
+
it('契約①: 以toFloating入表之列, 經model讀回之Date再作條件輸入即二次位移(LA: $eq不命中該列、$ne命中; UTC: 恆等)', async function() {
|
|
188
|
+
for (let tz of ['America/Los_Angeles', 'Asia/Taipei', 'UTC']) {
|
|
189
|
+
await withTz(tz, async () => {
|
|
190
|
+
await load([['a', toFloating('2026-10-20 01:00:00')], ['b', toFloating('2026-10-21 01:00:00')]])
|
|
191
|
+
let row = (await md.findAll({ where: { id: 'a' } }))[0]
|
|
192
|
+
let back = row.get('d')
|
|
193
|
+
assert.ok(back instanceof Date)
|
|
194
|
+
assert.strictEqual(back.toISOString(), '2026-10-20T01:00:00.000Z', '讀回之Date即浮動瞬間')
|
|
195
|
+
//原條件(牆上時間字串)命中
|
|
196
|
+
assert.deepStrictEqual(await idsOf({ d: '2026-10-20T01:00:00' }, { dateNoTz: 'floating' }), ['a'])
|
|
197
|
+
//讀回之Date當條件
|
|
198
|
+
let eq = await idsOf({ d: { $eq: back } }, { dateNoTz: 'floating' })
|
|
199
|
+
let ne = await idsOf({ d: { $ne: back } }, { dateNoTz: 'floating' })
|
|
200
|
+
if (tz === 'UTC') {
|
|
201
|
+
assert.deepStrictEqual(eq, ['a'])
|
|
202
|
+
assert.deepStrictEqual(ne, ['b'])
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
assert.deepStrictEqual(eq, [], `${tz}: 二次位移後不應命中該列`)
|
|
206
|
+
assert.deepStrictEqual(ne, ['a', 'b'], `${tz}: 二次位移後$ne命中該列(可被delAll刪除)`)
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
//契約②: toFloating回null者呼叫端須整批拒收, 不得以原值或NULL代入
|
|
213
|
+
it('契約②: 參考之呼叫端轉換遇toFloating為null即整批拒收(資料表不變); 以NULL代入則該列被範圍條件漏掉、被IS NULL命中', async function() {
|
|
214
|
+
let encode = (rows) => rows.map((r) => {
|
|
215
|
+
let v = r.d === null || r.d === undefined ? r.d : toFloating(r.d)
|
|
216
|
+
if (v === null) {
|
|
217
|
+
throw new Error(`invalid wall time at [${r.id}]`)
|
|
218
|
+
}
|
|
219
|
+
return { id: r.id, d: v }
|
|
220
|
+
})
|
|
221
|
+
await load([['k', toFloating('2020-01-01 00:00:00')]])
|
|
222
|
+
let rows = [{ id: 'a', d: '2026-01-01 00:00:00' }, { id: 'b', d: '2026-02-30 00:00:00' }, { id: 'c', d: '2026-03-01 00:00:00' }]
|
|
223
|
+
let r = null
|
|
224
|
+
try {
|
|
225
|
+
encode(rows)
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
r = msgOf(e)
|
|
229
|
+
}
|
|
230
|
+
assert.strictEqual(r, 'invalid wall time at [b]')
|
|
231
|
+
assert.deepStrictEqual(await allIds(), ['k'], '整批拒收時不得有任何列寫入')
|
|
232
|
+
|
|
233
|
+
//反例: 以NULL代入
|
|
234
|
+
let filled = rows.map((x) => ({ id: x.id, d: toFloating(x.d) })) //b為null: 即以NULL代入
|
|
235
|
+
assert.strictEqual(filled[1].d, null)
|
|
236
|
+
let ins2 = await w.insert(filled, cs)
|
|
237
|
+
assert.strictEqual(ins2.nInserted, 3)
|
|
238
|
+
assert.ok((await idsOf({ d: { $lt: '2099-01-01T00:00:00' } }, { dateNoTz: 'floating' })).indexOf('b') < 0, 'NULL列不被範圍條件命中')
|
|
239
|
+
assert.deepStrictEqual(await idsOf({ d: null }, { dateNoTz: 'floating' }), ['b'], 'NULL列被IS NULL命中')
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
//契約④: 條件走'floating'與資料側toFloating同進同退
|
|
243
|
+
it('契約④: 同進同退——數據列經toFloating + floating條件得正確結果; 混用(數據列未經toFloating)於LA靜默誤命中', async function() {
|
|
244
|
+
await withTz('America/Los_Angeles', async () => {
|
|
245
|
+
let cond = { d: { $lt: '2026-10-20T04:00:00' } }
|
|
246
|
+
|
|
247
|
+
//同進: 牆上01:00 < 04:00 命中, 牆上05:00不命中
|
|
248
|
+
await load([['a', toFloating('2026-10-20 01:00:00')], ['b', toFloating('2026-10-20 05:00:00')]])
|
|
249
|
+
assert.deepStrictEqual(await idsOf(cond, { dateNoTz: 'floating' }), ['a'])
|
|
250
|
+
|
|
251
|
+
//混用: 數據列以本地分量建構之Date入表(PDT, UTC-7: 牆上01:00 = 08:00Z), 條件為浮動值(04:00Z)
|
|
252
|
+
await load([['a', new Date(2026, 9, 20, 1, 0, 0)], ['b', new Date(2026, 9, 20, 5, 0, 0)]])
|
|
253
|
+
assert.deepStrictEqual(await idsOf(cond, { dateNoTz: 'floating' }), [], '混用: 牆上01:00之列本應命中而被漏掉(靜默誤命中)')
|
|
254
|
+
//另一方向: 浮動數據列配'local'條件亦錯位(牆上01:00 = 01:00Z, 'local'條件04:00本地 = 11:00Z)
|
|
255
|
+
await load([['a', toFloating('2026-10-20 01:00:00')], ['b', toFloating('2026-10-20 05:00:00')], ['c', toFloating('2026-10-20 15:00:00')]])
|
|
256
|
+
assert.deepStrictEqual(await idsOf(cond, { dateNoTz: 'local' }), ['a', 'b'], '混用: 浮動數據列配local條件, 牆上05:00之列被誤命中')
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
}
|