import path from 'path'
import fs from 'fs'
import Sequelize from 'sequelize'
import jetOpen, { CODE_DUP_KEY } from './jet.mjs'
import cloneDeep from 'lodash-es/cloneDeep.js'
import get from 'lodash-es/get.js'
import map from 'lodash-es/map.js'
import each from 'lodash-es/each.js'
import size from 'lodash-es/size.js'
import split from 'lodash-es/split.js'
import keys from 'lodash-es/keys.js'
import pick from 'lodash-es/pick.js'
import omit from 'lodash-es/omit.js'
import isEqual from 'lodash-es/isEqual.js'
import chunk from 'lodash-es/chunk.js'
import evem from 'wsemi/src/evem.mjs'
import genIDSeq from 'wsemi/src/genIDSeq.mjs'
import isarr from 'wsemi/src/isarr.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import isobj from 'wsemi/src/isobj.mjs'
import iseobj from 'wsemi/src/iseobj.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import arrHas from 'wsemi/src/arrHas.mjs'
import pmQueue from 'wsemi/src/pmQueue.mjs'
import _genModelsByTabs from 'w-orm-reladb/src/genModelsByTabs.mjs'
import {
getSelectAll,
getSelectByPk,
getSelectByPks,
getInsert,
getUpdate,
getDeleteByPk,
getDeleteByPks,
getDeleteAll
} from './getCmd.mjs'
import { initSequelize, queryMemory } from './queryMemory.mjs'
//放置於全域宣告, 若使用useStable=true才可於當創建多實例時仍能全域控管
let pmq = pmQueue(1)
//單一DELETE語句所能容納之主鍵數量上限, 避免IN清單過長超出Jet之查詢文字長度限制
const nPkPerDelete = 200
/**
* 操作Access mdb資料庫
*
* 註: 主鍵欄位取自opt.pk,預設為id,支援由呼叫端指定
* 註: 本套件經connMDB.exe操作Windows內建之Jet 4.0引擎,僅支援.mdb(Jet4)不支援.accdb
* 註: Jet引擎之字串比對不分大小寫,故主鍵之[命中]認定亦不分大小寫,
* 且select之記憶體過濾層已一併對齊(見queryMemory.mjs),令兩者判定一致
* 註: sqlite之NOCASE僅折疊ASCII之A-Z,與Jet對部分非ASCII字元之折疊不同,
* 故僅大小寫相異之非ASCII主鍵於select與selectByPk仍可能判定不一致
* 註: insert與save之原子性倚賴資料表主鍵欄位具唯一約束(PRIMARY KEY),資料表建立時須含之
*
* @class
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {String} [opt.url='mdb://username:password'] 輸入連接資料庫字串,資料庫僅可選'mdb',預設'mdb://username:password'
* @param {String} [opt.storage='./worm.mdb'] 輸入mdb資料庫檔案位置字串,預設'./worm.mdb'
* @param {Boolean} [opt.useEncryption=false] 輸入是否使用加密保護,型別為布林值,預設false
* @param {String} [opt.db='worm'] 輸入使用資料庫名稱字串,mdb並不需要給只是配合w-orm系列統一設定,預設'worm'
* @param {String} [opt.cl='test'] 輸入使用資料表名稱字串,預設'test'
* @param {String} [opt.fdModels='./models'] 輸入資料表設定檔所在資料夾字串,預設'./models'
* @param {String} [opt.modelType='js'] 輸入資料表設定檔類型字串,可有'js'、'json',預設'js'
* @param {Boolean} [opt.logging=false] 輸入是否輸出實際執行的sql指令布林值,預設false
* @param {String} [opt.pk='id'] 輸入數據主鍵字串,預設'id'
* @param {Boolean} [opt.autoGenPk=true] 輸入若數據主鍵欄位沒給時則自動給予隨機uuid,型別為布林值,預設true
* @param {Boolean} [opt.useStable=true] 輸入是否使用穩定模式,使用佇列管理同時只能進行一種操作故會犧牲效能,型別為布林值,預設true
* @returns {Object} 回傳操作資料庫物件,各事件功能詳見說明
*/
function WOrmMdb(opt = {}) {
let ss
let u
let sequelize = null
//default
if (!isestr(opt.url)) {
opt.url = 'mdb://username:password'
}
if (!isestr(opt.db)) {
opt.db = 'worm'
}
if (!isestr(opt.cl)) {
opt.cl = 'test'
}
if (!isestr(opt.fdModels)) {
opt.fdModels = './models'
}
if (!isestr(opt.modelType)) {
opt.modelType = 'js'
}
opt.logging = (opt.logging === true)
if (!isestr(opt.pk)) {
opt.pk = 'id'
}
if (!isbol(opt.autoGenPk)) {
opt.autoGenPk = true
}
if (!isbol(opt.useStable)) {
opt.useStable = true
}
if (!isestr(opt.storage)) {
opt.storage = './worm.mdb'
}
opt.storage = path.resolve(opt.storage)
if (!isbol(opt.useEncryption)) {
opt.useEncryption = false
}
//Op
let Op = Sequelize.Op
//ee, 採wsemi之evem(即eventemitter3), 其於'error'無監聽者時僅回傳false而不拋出,
//故本套件之操作行為不因呼叫端有無註冊監聽而改變; Node內建之EventEmitter具該拋出語義, 不可用
let ee = evem()
//opt.url解析失敗一律拋出, 因其屬呼叫端未履行契約, 且解析失敗後各函數皆無從運作,
//建構為同步而無從以Promise.reject回報, 拋出即為reject之同步對應;
//若僅console.log而回傳未綁定各函數之ee, 錯誤不經任何管道抵達呼叫端, 呼叫端僅得到
//[w.select is not a function]之無關訊息, 與本規格[錯誤不得靜默]之通則相違
let throwUrlErr = (msg) => {
throw new Error(`${msg}, opt.url[${opt.url}]`)
}
//dialect
let dialect
ss = split(opt.url, '://')
dialect = get(ss, 0, null)
if (!dialect) {
throwUrlErr('no dialect in opt.url')
}
if (dialect !== 'mdb') {
throwUrlErr(`dialect[${dialect}] is not mdb`)
}
u = get(ss, 1, '') //另存給後面使用
//username, password
let username
let password
ss = split(u, '@')
u = get(ss, 1, '') //另存給後面使用
ss = get(ss, 0, '')
ss = split(ss, ':')
if (size(ss) !== 2) {
throwUrlErr('invalid username or password in opt.url')
}
username = get(ss, 0, '')
password = get(ss, 1, '')
if (opt.useEncryption) {
if (username === '') {
throwUrlErr('invalid username in opt.url')
}
if (password === '') {
throwUrlErr('invalid password in opt.url')
}
}
/**
* 取資料庫密碼,加密檔之密碼組成為`${username}:${password}`
*
* @ignore
* @returns {String} 回傳資料庫密碼字串,未使用加密時為空字串
*/
function getDbPassword() {
if (opt.useEncryption) {
return `${username}:${password}`
}
return ''
}
/**
* 取錯誤訊息字串,供逐筆結果之err欄位與error事件使用
*
* @ignore
* @param {Error|String} err 輸入錯誤物件或字串
* @returns {String} 回傳錯誤訊息字串
*/
function getErrMsg(err) {
//message
let message = get(err, 'message')
if (isestr(message)) {
return message
}
return String(err)
}
/**
* 發出change事件,訂閱函數拋錯不得影響本次操作之結果,故另包try並自行吞掉
*
* @ignore
* @param {String} mode 輸入操作別字串
* @param {Object|Array|null} data 輸入本次操作之數據
* @param {Object|Array} res 輸入本次操作之結果
* @returns {undefined} 無回傳值
*/
function emitChange(mode, data, res) {
try {
ee.emit('change', mode, data, res)
}
catch (err) {
console.log(err)
}
}
/**
* 發出error事件,操作發生錯誤時發出,錯誤訊息一律轉為字串
* 註: 事件僅為附加通知,所送出之資訊必另有正規管道——整批性錯誤經Promise.reject,逐筆失敗經該筆之err欄位
* 註: 訂閱函數拋錯不得影響本次操作之結果,故另包try並自行吞掉
* 註: 正常結果不得發出本事件,如insert全數已存在、save合併後內容相同、del主鍵未命中、selectByPk查無數據
*
* @ignore
* @param {String} mode 輸入操作別字串
* @param {Object|Array|null} data 輸入本次操作之數據
* @param {Error|String} err 輸入錯誤物件或字串
* @returns {undefined} 無回傳值
*/
function emitError(mode, data, err) {
try {
ee.emit('error', mode, data, getErrMsg(err))
}
catch (errEmit) {
console.log(errEmit)
}
}
/**
* 判定主鍵值是否有效
* 註: select以外之六函數對主鍵之認定一律以本函數為準,令selectByPk之[命中]與insert、save、del內對既有數據之認定一致
*
* @ignore
* @param {*} v 輸入主鍵值
* @returns {Boolean} 回傳主鍵值是否有效布林值
*/
function isEffPk(v) {
//非空字串
if (isestr(v)) {
return true
}
//數值, 含0, 供整數型主鍵使用
if (isnum(v)) {
return true
}
return false
}
/**
* 取主鍵值之比對鍵
* 註: Jet之字串比對不分大小寫,故核對主鍵是否已存在時亦須不分大小寫,
* 否則以'ID-X'插入而庫內存有'id-x'時,會誤判為[非主鍵衝突]而錯報為整批性錯誤
*
* @ignore
* @param {String|Number} v 輸入主鍵值
* @returns {String} 回傳比對鍵字串
*/
function pkKey(v) {
return String(v).toLowerCase()
}
/**
* 檢查並補齊單筆數據之主鍵
* autoGenPk為true時未帶有效主鍵者自動產生,為false時往外拋
* 註: 未帶有效主鍵屬呼叫端未履行契約而非某一筆資料本身之問題,故為整批性錯誤而不降級為該筆ok為0,
* 若降級為逐筆結果,呼叫端易於整批resolve之下漏看,使[忘了給主鍵]靜默變成[少寫了幾筆]
* 註: 本函數須於任何寫入之前一次對全部數據完成,令拋錯時同批之有效筆數亦不會被寫入
* 註: 自動產生之主鍵值為字串,故補值前檢查主鍵欄位確為字串類型,不符者以明確訊息拋出,
* 而非任由資料庫回報型別錯誤; 本套件之model定義於手上得知主鍵型別,故不適用T6之[套件無從得知主鍵型別]例外
*
* @ignore
* @param {Object} md 輸入資料表model物件
* @param {Object} v 輸入數據物件
* @param {Number} k 輸入數據於陣列內之索引
* @returns {Object} 回傳補齊主鍵之數據物件
*/
function procPk(md, v, k) {
//check
if (isEffPk(get(v, opt.pk))) {
return v
}
//check, autoGenPk為false時主鍵須由呼叫端自備
if (!opt.autoGenPk) {
throw new Error(`invalid data[${k}].${opt.pk}, autoGenPk is false`)
}
//at
let at = get(md, `rawAttributes.${opt.pk}`)
if (!iseobj(at)) {
throw new Error(`can not find pk column [${opt.pk}] in model [${opt.cl}]`)
}
//check, 自動產生之主鍵值為字串, 主鍵欄位須為字串類型方能容納
let tp = get(at, 'type.key', '')
if (!arrHas(tp, ['STRING', 'TEXT', 'CHAR', 'UUID', 'CITEXT'])) {
throw new Error(`autoGenPk is true but pk column [${opt.pk}] in model [${opt.cl}] is [${tp}], auto generated pk is a string, need to set autoGenPk to false and supply pk by caller`)
}
//genIDSeq, 為UUIDv7格式而具單調遞增性, 主鍵接近順序遞增可減少索引頁面分裂
v[opt.pk] = genIDSeq()
return v
}
/**
* 初始化操作實例
*
* 註: connMDB.exe為one-shot程序,每次呼叫各自開啟與關閉連線,故實例本身不持有資料庫連線;
* sequelize僅用於匯入models以取得欄位定義,不承載任何數據
*
* @memberOf WOrmMdb
* @returns {Promise} 回傳Promise,resolve回傳實例物件,reject回傳錯誤訊息
*/
async function initAdodb() {
let err = null
//jet, 透過connMDB.exe操作, 使用Windows內建Jet 4.0引擎, 目標機不需安裝任何東西
//查詢結果由exe逐列串流寫jsonl, 不受exe程序記憶體限制, 可支援至mdb格式上限(2GB)
let jet = jetOpen({ path: opt.storage, password: getDbPassword() })
//initSequelize, 匯入失敗屬影響本次操作全部數據之整批性錯誤
let mds = null
await initSequelize(opt)
.then((r) => {
sequelize = r.sequelize
mds = r.mds
})
.catch((e) => {
err = getErrMsg(e)
})
return {
mds,
jet,
err,
close: () => {
return closeSequelize('external')
},
}
}
/**
* 關閉sequelize
*
* @memberOf WOrmMdb
* @returns {Promise} 回傳Promise,resolve代表關閉成功,reject回傳錯誤訊息
*/
async function closeSequelize(from) {
if (sequelize !== null) {
await sequelize.close()
//console.log(from, 'sequelize.close()')
}
sequelize = null
//console.log(from, 'sequelize = null')
}
/**
* 取實例內之資料表model
*
* @ignore
* @param {Object} si 輸入實例物件
* @returns {Object} 回傳model物件
*/
function getMd(si) {
let md = get(si, `mds.${opt.cl}`)
if (!md) {
throw new Error(`can not find model [${opt.cl}]`)
}
return md
}
/**
* 取資料表欄位名陣列
*
* @ignore
* @param {Object} md 輸入model物件
* @returns {Array} 回傳欄位名陣列
*/
function getCols(md) {
return keys(get(md, 'rawAttributes', {}))
}
/**
* 將mongo風格之查詢條件轉為sequelize運算子
*
* @ignore
* @param {Object} find 輸入查詢條件物件
* @returns {Object} 回傳轉譯後之查詢條件物件
*/
function getUseFind(find) {
function getKNew(k) {
let kNew = null
if (k === 'regex') {
kNew = Op.substring
}
else if (k === 'options') {
kNew = null
}
else if (k === 'nin') {
kNew = Op.notIn
}
else {
kNew = Op[k]
}
return kNew
}
function cvObj(o) {
let oNew = {}
each(o, (v, k) => {
let kNew = k
if (k.indexOf('$') >= 0) {
k = k.replace('$', '')
kNew = getKNew(k) //只針對開頭為$的指令轉譯
}
let vNew = v
if (isarr(v)) {
vNew = cvArray(v)
}
else if (isobj(v)) {
vNew = cvObj(v)
}
if (kNew !== null) {
oNew[kNew] = vNew
}
})
return oNew
}
function cvArray(o) {
let oNew = []
each(o, (v) => {
let vNew = v
if (isarr(v)) {
vNew = cvArray(v)
}
else if (isobj(v)) {
vNew = cvObj(v)
}
oNew.push(vNew)
})
return oNew
}
//find
if (!isobj(find)) {
find = {}
}
find = cloneDeep(find)
//useFind
let useFind = cvObj(find)
return useFind
}
/**
* 由Jet取回全表數據後於記憶體內依條件過濾
*
* 註: Access之SQL方言不支援$in、$nin、$regex等條件,故不將條件送Jet而由記憶體過濾
*
* @ignore
* @param {Object} si 輸入實例物件
* @param {Object} find 輸入查詢條件物件
* @returns {Promise} 回傳Promise,resolve回傳數據陣列
*/
async function findAll(si, find) {
//md
let md = getMd(si)
//data, 先查詢出access mdb內全部資料
let data = await si.jet.query(getSelectAll(opt.cl))
//useFind
let useFind = getUseFind(find)
//queryMemory
let rs = await queryMemory(opt, get(md, 'rawAttributes', {}), data, useFind)
return rs
}
/**
* 查詢數據
*
* 註: 恆回傳陣列,無符合數據回傳空陣列而不回傳null
* 註: find未給或為空物件時回傳全部數據
* 註: 本函數不得有副作用,故不創建資料表
*
* @memberOf WOrmMdb
* @param {Object} [find={}] 輸入查詢條件物件
* @param {Object} [option={}] 輸入設定物件,預設為{}
* @param {Object} [option.instance=null] 輸入實例instance物件,預設為null
* @returns {Promise} 回傳Promise,resolve回傳數據陣列,reject回傳錯誤訊息
*/
async function select(find = {}, option = {}) {
//instance
let instance = get(option, 'instance', null)
//isErr, res
let isErr = false
let res = null
//si
let si = instance
if (instance === null) {
si = await initAdodb()
}
try {
//check, 連線或匯入models失敗影響本次操作之全部數據, 屬整批性錯誤
if (si.err) {
throw si.err
}
//findAll, 恆回傳陣列, 無符合數據為空陣列
res = await findAll(si, find)
if (!isarr(res)) {
res = []
}
}
catch (err) {
isErr = true
res = err
}
finally {
//closeSequelize
if (instance === null) { //內部自動初始化得close
await closeSequelize('select')
}
}
//check
if (isErr) {
emitError('select', null, res)
return Promise.reject(res)
}
return res
}
/**
* 由主鍵查詢單筆數據,因由Jet依主鍵取值且僅回傳單筆,不需如select提取全部數據再過濾,故數據量大時效能較佳
*
* 註: 主鍵欄位取自opt.pk,預設為id,支援由呼叫端指定
* 註: 主鍵未命中或主鍵值無效皆回傳null而不reject,[命中]之判定基準與insert、save、del內對既有數據之認定一致
* 註: Jet之字串比對不分大小寫,故主鍵之命中判定亦不分大小寫
* 註: 本函數不得有副作用,故不創建資料表
*
* @memberOf WOrmMdb
* @param {String|Number} pk 輸入主鍵值,即數據內opt.pk欄位之值
* @param {Object} [option={}] 輸入設定物件,預設為{}
* @param {Object} [option.instance=null] 輸入實例instance物件,預設為null
* @returns {Promise} 回傳Promise,resolve回傳數據物件,若無此主鍵或主鍵值無效則回傳null,reject回傳錯誤訊息
*/
async function selectByPk(pk, option = {}) {
//check, 未給有效主鍵值視為查無數據, 不送查詢以免無效值被轉為null而誤中其他數據
if (!isEffPk(pk)) {
return null
}
//instance
let instance = get(option, 'instance', null)
//isErr, res
let isErr = false
let res = null
//si
let si = instance
if (instance === null) {
si = await initAdodb()
}
try {
//check
if (si.err) {
throw si.err
}
//query
let rs = await si.jet.query(getSelectByPk(opt.cl, opt.pk, pk))
//check, 判定基準與insert、save、del內對既有數據之認定一致
let v = get(rs, 0, null)
if (iseobj(v)) {
res = v
}
else {
//不存在主鍵, 回傳null
res = null
}
}
catch (err) {
isErr = true
res = err
}
finally {
//closeSequelize
if (instance === null) { //內部自動初始化得close
await closeSequelize('selectByPk')
}
}
//check
if (isErr) {
emitError('selectByPk', null, res)
return Promise.reject(res)
}
return res
}
/**
* 核對指定之主鍵是否確實存在於資料表
*
* 註: Jet之重複鍵錯誤碼3022涵蓋主鍵與其他唯一索引之衝突,兩者無從由錯誤碼區辨,
* 故須另行核對;若該主鍵並不存在,即表示係撞及其他唯一索引,屬影響該筆以外之錯誤而不得視為[已存在則跳過]
*
* @ignore
* @param {Object} si 輸入實例物件
* @param {Array} pkvs 輸入主鍵值陣列
* @returns {Promise} 回傳Promise,resolve回傳已存在之主鍵比對鍵Set
*/
async function getExistPks(si, pkvs) {
//hs
let hs = new Set()
//check
if (size(pkvs) === 0) {
return hs
}
//cmds, 分批以免IN清單過長
let cmds = map(chunk(pkvs, nPkPerDelete), (vs) => {
return {
type: 'query',
sql: getSelectByPks(opt.cl, opt.pk, vs),
}
})
//run
let r = await si.jet.run(cmds)
if (!r.ok) {
throw new Error(r.error)
}
//collect
each(r.results, (rr) => {
each(get(rr, 'rows', []), (v) => {
hs.add(pkKey(get(v, opt.pk)))
})
})
return hs
}
/**
* 插入數據,僅於主鍵不存在時寫入,已存在者跳過且不覆寫
*
* 由Jet於主鍵之唯一約束上原子完成[檢查主鍵不存在]與[寫入],併發時同一主鍵僅有一次成功
*
* 註: n為輸入筆數即本次嘗試插入之基準,nInserted為實際插入筆數,全數已存在而nInserted為0屬正常結果
* 註: 同批含重複主鍵時僅首筆計入nInserted,其餘視為已存在
* 註: opt.autoGenPk為true(預設)時未帶有效主鍵者自動產生,為false時未帶有效主鍵即reject且同批皆不寫入
* 註: 全部插入語句於單次呼叫exe內循序送出且不因單筆衝突而中止,故一次往返即可取得精確之nInserted
* 註: option.returnList為true時改回傳與輸入等長且保序之逐筆陣列[{n,nInserted,ok}],
* 供呼叫端得知[是哪幾筆]為新資料而非僅[有幾筆];逐筆元素之n與ok恆為1,
* 因insert之任何錯誤皆屬整批性錯誤而reject,故不出現ok為0與err。
* 回傳形式之切換為靜態,僅由本選項之取值決定,不因數據內容或執行結果而變
*
* @memberOf WOrmMdb
* @param {Object|Array} data 輸入數據物件或陣列
* @param {Object} [option={}] 輸入設定物件,預設為{}
* @param {Object} [option.instance=null] 輸入實例instance物件,預設為null
* @param {Boolean} [option.returnList=false] 輸入是否改回傳與輸入等長之逐筆結果陣列,預設為false
* @returns {Promise} 回傳Promise,resolve回傳插入結果{n,nInserted,ok},或於option.returnList為true時回傳[{n,nInserted,ok}],reject回傳錯誤訊息
*/
async function insert(data, option = {}) {
//returnList
let returnList = get(option, 'returnList', false) === true
//check
if (!iseobj(data) && !isearr(data)) {
if (returnList) {
return []
}
return {
n: 0,
nInserted: 0,
ok: 1,
}
}
//instance
let instance = get(option, 'instance', null)
//cloneDeep
data = cloneDeep(data)
//isErr, res
let isErr = false
let res = null
//si
let si = instance
if (instance === null) {
si = await initAdodb()
}
try {
//check
if (si.err) {
throw si.err
}
//md, cols
let md = getMd(si)
let cols = getCols(md)
//check
if (!isarr(data)) {
data = [data]
}
//procPk, 須於任何寫入之前一次完成, 令autoGenPk為false而拋錯時同批之有效筆數亦不會被寫入
data = map(data, (v, k) => {
return procPk(md, v, k)
})
//nAll, n之基準為輸入筆數
let nAll = size(data)
//cmds, stopOnError為false令單筆撞鍵不中止其餘筆數, 一次往返即完成整批
let cmds = map(data, (v) => {
return {
type: 'execute',
sql: getInsert(opt.cl, cols, v),
stopOnError: false,
}
})
//run
let r = await si.jet.run(cmds)
//bInss, 逐筆是否已插入, 與輸入等長且保序
//dups, 重複鍵者, 待核對係主鍵已存在或撞及其他唯一索引
let bInss = map(data, () => {
return false
})
let dups = []
each(r.results, (rr, k) => {
if (rr.ok) {
bInss[k] = true
return
}
if (rr.errorCode === CODE_DUP_KEY) {
dups.push({ k, rr })
return
}
//其餘錯誤影響本次操作之數據而非僅該筆, 屬整批性錯誤
throw new Error(rr.error)
})
//check, 核對重複鍵者是否確為主鍵已存在
if (size(dups) > 0) {
let pkvs = map(dups, (v) => {
return get(data, `${v.k}.${opt.pk}`)
})
let hs = await getExistPks(si, pkvs)
each(dups, (v) => {
let pkv = get(data, `${v.k}.${opt.pk}`)
if (!hs.has(pkKey(pkv))) {
//主鍵並不存在, 即撞及其他唯一索引, 不得靜默跳過
throw new Error(v.rr.error)
}
})
}
//res, 全數已存在而nInserted為0屬正常結果, 不視為錯誤
if (returnList) {
//逐筆結果, 逐筆元素之n與ok恆為1, 資訊由nInserted承載
res = map(bInss, (b) => {
return {
n: 1,
nInserted: b ? 1 : 0,
ok: 1,
}
})
}
else {
res = {
n: nAll,
nInserted: size(bInss.filter((b) => {
return b
})),
ok: 1,
}
}
}
catch (err) {
isErr = true
res = err
}
finally {
//closeSequelize
if (instance === null) { //內部自動初始化得close
await closeSequelize('insert')
}
}
//check
if (isErr) {
emitError('insert', data, res)
return Promise.reject(res)
}
//emit, 須於結果定案後發出
emitChange('insert', data, res)
return res
}
/**
* 批次插入數據,全批視為一個單位:全部插入成功,或一筆都不寫入
*
* 本函數非insert之加速版,兩者衝突政策不同:insert於主鍵已存在時跳過該筆且整批ok為1,
* 本函數則整批reject且不寫入任何一筆,同批含重複主鍵者亦視為衝突。
* 確無衝突時兩者之可觀察結果完全相同,差異僅於有衝突時顯現。
*
* 註: n為輸入筆數即本次嘗試插入之基準;nInserted於成功時恆等於n,雖無額外資訊仍保留,
* 令呼叫端得與insert共用同一段結果處理程式碼
* 註: 不提供逐筆結果,故不出現ok為0與err;需要逐筆處置者改用insert
* 註: opt.autoGenPk為true(預設)時未帶有效主鍵者自動產生,為false時未帶有效主鍵即reject且同批皆不寫入
* 註: [全有全無]以Jet之交易(OleDbTransaction)包覆全部插入語句達成,任一筆失敗即回滾,
* 故reject之後資料庫狀態與呼叫前相同,無須補償動作;同批含重複主鍵者亦於交易內被偵測為衝突
*
* @memberOf WOrmMdb
* @param {Object|Array} data 輸入數據物件或陣列
* @param {Object} [option={}] 輸入設定物件,預設為{}
* @param {Object} [option.instance=null] 輸入實例instance物件,預設為null
* @returns {Promise} 回傳Promise,resolve回傳插入結果{n,nInserted,ok},reject回傳錯誤訊息
*/
async function insertBulk(data, option = {}) {
//check
if (!iseobj(data) && !isearr(data)) {
return {
n: 0,
nInserted: 0,
ok: 1,
}
}
//instance
let instance = get(option, 'instance', null)
//cloneDeep
data = cloneDeep(data)
//isErr, res
let isErr = false
let res = null
//si
let si = instance
if (instance === null) {
si = await initAdodb()
}
try {
//check
if (si.err) {
throw si.err
}
//md, cols
let md = getMd(si)
let cols = getCols(md)
//check
if (!isarr(data)) {
data = [data]
}
//procPk, 須於任何寫入之前一次完成, 令autoGenPk為false而拋錯時同批皆不會被寫入
data = map(data, (v, k) => {
return procPk(md, v, k)
})
//nAll, n之基準為輸入筆數
let nAll = size(data)
//cmds, 未加任何跳過選項, 故任一筆撞鍵即整批失敗, 同批重複主鍵亦於此被偵測為衝突
let cmds = map(data, (v) => {
return {
type: 'execute',
sql: getInsert(opt.cl, cols, v),
}
})
//run, 以交易包覆令失敗時回滾而不寫入任何一筆
let r = await si.jet.run(cmds, { useTransaction: true })
//check
if (!r.ok) {
throw new Error(r.error)
}
//res, 全有全無故成功時nInserted恆等於n
res = {
n: nAll,
nInserted: nAll,
ok: 1,
}
}
catch (err) {
isErr = true
res = err
}
finally {
//closeSequelize
if (instance === null) { //內部自動初始化得close
await closeSequelize('insertBulk')
}
}
//check
if (isErr) {
emitError('insertBulk', data, res)
return Promise.reject(res)
}
//emit, 須於結果定案後發出
emitChange('insertBulk', data, res)
return res
}
/**
* 儲存數據,以主鍵為準更新既有數據,未給之欄位保留;主鍵不存在且option.autoInsert為true(預設)時改為插入
*
* 採條件寫入配合衝突偵測與重試: 每次寫入本身為單一條件式原子語句——插入由主鍵之唯一約束判定、
* 更新由WHERE主鍵之比對判定,皆非由預讀值決定其成敗;預讀僅用於選擇發出哪一條語句,
* 選錯不會產生錯誤結果,只會被偵測並重試(插入撞既有主鍵、更新未命中任何列),故縱使預讀值已過期,
* 最終結果仍與該次操作單獨執行時相同。
*
* 註: 回傳陣列恆與輸入等長,輸入單一物件亦回傳長度1之陣列
* 註: n為主鍵命中筆數,值為0或1,命中(不論內容有無變更)或經插入而產生皆為1;nInserted與nModified恆同時出現
* 註: [內容相同]之判定基準為將待寫入物件合併進現值後與現值相同,非待寫入物件與現值全等,
* 故只給部份欄位且該些欄位值皆與現值相同時,合併結果等於現值,nModified為0。
* 合併取淺層以與Jet整欄取代之寫入行為一致,令nModified忠實反映資料庫端是否真的寫入
* 註: 不以UPDATE之影響列數決定nModified,因Jet之影響列數為[符合WHERE之列數]而非[內容真的有變之列數]
* (已實測: 將欄位設為與現值相同之值仍回報影響1列)
* 註: 本筆失敗不中斷整批,該筆以ok為0並附err回報
* 註: opt.autoGenPk為true(預設)時未帶有效主鍵者自動產生,為false時未帶有效主鍵即reject且同批皆不寫入
* 註: 各回合之預讀與寫入皆整批送出,故N筆數據於無衝突時僅需2次呼叫exe而非2N次
*
* @memberOf WOrmMdb
* @param {Object|Array} data 輸入數據物件或陣列
* @param {Object} [option={}] 輸入設定物件,預設為{}
* @param {Object} [option.instance=null] 輸入實例instance物件,預設為null
* @param {Boolean} [option.autoInsert=true] 輸入是否於儲存時發現原本無數據,則自動改以插入處理,預設為true
* @returns {Promise} 回傳Promise,resolve回傳與輸入等長之儲存結果陣列[{n,nInserted,nModified,ok}],reject回傳錯誤訊息
*/
async function save(data, option = {}) {
//check
if (!iseobj(data) && !isearr(data)) {
return []
}
//cloneDeep
data = cloneDeep(data)
//instance
let instance = get(option, 'instance', null)
//autoInsert
let autoInsert = get(option, 'autoInsert', true)
//nTry, 預測錯誤時重試, 給予有限次數令其收斂
let nTry = 3
//isErr, res
let isErr = false
let res = null
//inserteds, 供結果定案後發出insert事件用
let inserteds = []
//si
let si = instance
if (instance === null) {
si = await initAdodb()
}
try {
//check
if (si.err) {
throw si.err
}
//md, cols
let md = getMd(si)
let cols = getCols(md)
//check
if (!isarr(data)) {
data = [data]
}
//procPk, 須於任何寫入之前一次完成, 令autoGenPk為false而拋錯時同批之有效筆數亦不會被寫入
data = map(data, (v, k) => {
return procPk(md, v, k)
})
//rests, 逐筆結果, 未定案者為null
let rests = map(data, () => {
return null
})
//pends, 待處理之索引
let pends = map(data, (v, k) => {
return k
})
for (let iTry = 0; iTry < nTry && size(pends) > 0; iTry++) {
//預讀, 供判斷內容是否相同之快速路徑, 其結果不用於決定寫入內容
let cmdsRead = map(pends, (i) => {
return {
type: 'query',
sql: getSelectByPk(opt.cl, opt.pk, get(data, `${i}.${opt.pk}`)),
stopOnError: false,
}
})
let rRead = await si.jet.run(cmdsRead)
//check, 連線開啟重試耗盡影響本次操作之全部數據, 屬整批性錯誤而非逐筆失敗(T4)
if (rRead.openFailed === true) {
throw new Error(rRead.error)
}
//writes, 由預讀結果選擇發出哪一條語句
let writes = []
each(pends, (i, j) => {
let v = data[i]
let rr = get(rRead, `results.${j}`, null)
//check, 預讀失敗視為本筆失敗, 不中斷整批
if (!rr || !rr.ok) {
rests[i] = {
n: 1,
nInserted: 0,
nModified: 0,
ok: 0,
err: getErrMsg(get(rr, 'error', 'can not read data')),
}
return
}
//_v, 現值
let _v = get(rr, 'rows.0', null)
if (iseobj(_v)) {
//主鍵存在
//_vt, vt, 以待寫入物件之非主鍵欄位淺層覆蓋現值後與現值比對
let _vt = omit(_v, opt.pk)
let vt = {
..._vt,
...pick(omit(v, opt.pk), cols),
}
//check, 內容相同者不寫入
if (isEqual(vt, _vt)) {
rests[i] = {
n: 1,
nInserted: 0,
nModified: 0,
ok: 1,
}
return
}
//sql, 單一UPDATE語句由WHERE主鍵原子完成查找與更新
let sql = getUpdate(opt.cl, cols, v, opt.pk, get(v, opt.pk))
if (sql === null) {
//無可更新之欄位, 等同未寫入
rests[i] = {
n: 1,
nInserted: 0,
nModified: 0,
ok: 1,
}
return
}
writes.push({ i, kind: 'update', sql })
return
}
//主鍵不存在且未開啟autoInsert則不寫入以免無中生有
if (!autoInsert) {
rests[i] = {
n: 0,
nInserted: 0,
nModified: 0,
ok: 1,
}
return
}
//sql, 單一INSERT語句由主鍵之唯一約束原子完成檢查與寫入
writes.push({
i,
kind: 'insert',
sql: getInsert(opt.cl, cols, v),
})
})
//run, 各寫入彼此獨立故不中止
if (size(writes) > 0) {
let rWrite = await si.jet.run(map(writes, (w) => {
return {
type: 'execute',
sql: w.sql,
stopOnError: false,
}
}))
//check, 連線開啟重試耗盡影響本次操作之全部數據, 屬整批性錯誤而非逐筆失敗(T4)
if (rWrite.openFailed === true) {
throw new Error(rWrite.error)
}
each(writes, (w, j) => {
let rr = get(rWrite, `results.${j}`, null)
//check
if (!rr) {
rests[w.i] = {
n: 1,
nInserted: 0,
nModified: 0,
ok: 0,
err: 'can not get result of writing',
}
return
}
if (rr.ok) {
if (w.kind === 'update') {
if (get(rr, 'n', 0) > 0) {
rests[w.i] = {
n: 1,
nInserted: 0,
nModified: 1,
ok: 1,
}
return
}
//該列已被他方刪除, 留待重試改走插入
return
}
rests[w.i] = {
n: 1,
nInserted: 1,
nModified: 0,
ok: 1,
}
inserteds.push(w.i)
return
}
//check, 他方已插入同一主鍵, 留待重試改走更新
if (w.kind === 'insert' && rr.errorCode === CODE_DUP_KEY) {
return
}
//其餘錯誤視為本筆失敗, 不中斷整批, 由呼叫端以ok與err判讀
rests[w.i] = {
n: 1,
nInserted: 0,
nModified: 0,
ok: 0,
err: getErrMsg(rr.error),
}
})
}
//pends, 取仍未定案者
pends = []
each(rests, (v, k) => {
if (v === null) {
pends.push(k)
}
})
}
//check, 重試耗盡仍未定案
each(pends, (i) => {
rests[i] = {
n: 1,
nInserted: 0,
nModified: 0,
ok: 0,
err: `can not save data by ${opt.pk}[${get(data, `${i}.${opt.pk}`)}] in ${nTry} tries`,
}
})
//res
res = rests
}
catch (err) {
isErr = true
res = err
}
finally {
//closeSequelize
if (instance === null) { //內部自動初始化得close
await closeSequelize('save')
}
}
//check
if (isErr) {
emitError('save', data, res)
return Promise.reject(res)
}
//emit, 逐筆事件於各筆結果定案後依輸入順序發出, 且早於整批change
each(res, (v, k) => {
if (arrHas(k, inserteds)) {
emitChange('insert', [data[k]], v)
}
if (v.ok === 0) {
emitError('save', [data[k]], v.err)
}
})
//emit, 須於結果定案後發出
emitChange('save', data, res)
return res
}
/**
* 刪除數據
*
* 註: 回傳陣列恆與輸入等長,各筆之n與nDeleted皆為主鍵命中筆數,值為0或1,未命中為0且屬正常結果
* 註: 判斷某筆是否真的被刪除一律以nDeleted為準
* 註: 未帶有效主鍵者為該筆之輸入問題,回ok為0與err且不送查詢條件(以免無效值被轉為null而誤中其他數據),
* 不中斷其餘筆數,以此與[主鍵未命中]之ok為1區辨
* 註: 本函數不受opt.autoGenPk影響,於任一設定下皆不補值
* 註: 由DELETE語句自身之影響列數判定命中,不另行查詢以免多一次往返且與刪除間存在競態
*
* @memberOf WOrmMdb
* @param {Object|Array} data 輸入數據物件或陣列,會查找各數據的opt.pk值,有存在者就刪除
* @param {Object} [option={}] 輸入設定物件,預設為{}
* @param {Object} [option.instance=null] 輸入實例instance物件,預設為null
* @returns {Promise} 回傳Promise,resolve回傳與輸入等長之刪除結果陣列[{n,nDeleted,ok}],reject回傳錯誤訊息
*/
async function del(data, option = {}) {
//check
if (!iseobj(data) && !isearr(data)) {
return []
}
//cloneDeep
data = cloneDeep(data)
//instance
let instance = get(option, 'instance', null)
//isErr, res
let isErr = false
let res = null
//si
let si = instance
if (instance === null) {
si = await initAdodb()
}
try {
//check
if (si.err) {
throw si.err
}
//check, 確認model存在, 令資料表設定有誤時為整批性錯誤
getMd(si)
//check
if (!isarr(data)) {
data = [data]
}
//rests
let rests = map(data, () => {
return null
})
//dels, 具有效主鍵者方送出刪除
let dels = []
each(data, (v, k) => {
//pkv
let pkv = get(v, opt.pk)
//check, 未帶有效主鍵者不補值亦不送查詢, 直接視為該筆無法處理
if (!isEffPk(pkv)) {
rests[k] = {
n: 0,
nDeleted: 0,
ok: 0,
err: `invalid ${opt.pk}[${pkv}]`,
}
return
}
dels.push({
k,
sql: getDeleteByPk(opt.cl, opt.pk, pkv),
})
})
//run, 各刪除彼此獨立故不中止
if (size(dels) > 0) {
let r = await si.jet.run(map(dels, (v) => {
return {
type: 'execute',
sql: v.sql,
stopOnError: false,
}
}))
//check, 連線開啟重試耗盡影響本次操作之全部數據, 屬整批性錯誤而非逐筆失敗(T4)
if (r.openFailed === true) {
throw new Error(r.error)
}
each(dels, (v, j) => {
let rr = get(r, `results.${j}`, null)
if (rr && rr.ok) {
//nDel, n與nDeleted皆為主鍵命中筆數, 未命中為0且屬正常結果
let nDel = isnum(rr.n) ? rr.n : 0
rests[v.k] = {
n: nDel,
nDeleted: nDel,
ok: 1,
}
return
}
//rest, 本筆失敗不中斷整批
rests[v.k] = {
n: 1,
nDeleted: 0,
ok: 0,
err: getErrMsg(get(rr, 'error', 'can not delete data')),
}
})
}
//res
res = rests
}
catch (err) {
isErr = true
res = err
}
finally {
//closeSequelize
if (instance === null) { //內部自動初始化得close
await closeSequelize('del')
}
}
//check
if (isErr) {
emitError('del', data, res)
return Promise.reject(res)
}
//emit, 逐筆失敗於各筆結果定案後依輸入順序發出, 且早於整批change
each(res, (v, k) => {
if (v.ok === 0) {
emitError('del', [data[k]], v.err)
}
})
//emit, 須於結果定案後發出
emitChange('del', data, res)
return res
}
/**
* 依條件刪除多筆數據,需與del分開,避免未傳數據導致直接刪除全表
*
* 註: n與nDeleted皆為實際刪除筆數,兩者恆相等,n不得取全表筆數
* 註: find未給或為空物件時刪除全部數據;條件無命中時回{n:0,nDeleted:0,ok:1},不視為錯誤
* 註: 帶條件時先以與select相同之記憶體過濾取得命中數據之主鍵清單,再依主鍵清單刪除,
* 令刪除範圍與select(find)之結果完全一致;若逕將條件送Jet,因Access不支援$in、$nin、$regex等運算子,
* 會出現[select查得到但delAll刪不到]或反之之不一致
* 註: 分批之DELETE以交易包覆,令任一批失敗時整體回滾而不留下部份刪除
*
* @memberOf WOrmMdb
* @param {Object} [find={}] 輸入刪除條件物件,不給予find則代表刪除全部數據
* @param {Object} [option={}] 輸入設定物件,預設為{}
* @param {Object} [option.instance=null] 輸入實例instance物件,預設為null
* @returns {Promise} 回傳Promise,resolve回傳刪除結果{n,nDeleted,ok},reject回傳錯誤訊息
*/
async function delAll(find = {}, option = {}) {
//find
if (!isobj(find)) {
find = {}
}
find = cloneDeep(find)
//instance
let instance = get(option, 'instance', null)
//isErr, res
let isErr = false
let res = null
//si
let si = instance
if (instance === null) {
si = await initAdodb()
}
try {
//check
if (si.err) {
throw si.err
}
//check, 確認model存在, 令資料表設定有誤時為整批性錯誤
getMd(si)
//nDel
let nDel = 0
if (size(keys(find)) === 0) {
//無條件, 直接清空全表, 不需先取主鍵清單
let rr = await si.jet.execute(getDeleteAll(opt.cl))
nDel = isnum(rr.n) ? rr.n : 0
}
else {
//有條件, 以與select相同之過濾取得命中數據
//rs
let rs = await findAll(si, find)
//pkvs, 命中數據之主鍵
let pkvs = []
each(rs, (v) => {
let pkv = get(v, opt.pk)
if (!isEffPk(pkv)) {
//無有效主鍵者無從精確定位, 不得靜默略過而使刪除少於預期
throw new Error(`can not delete data without valid ${opt.pk}`)
}
pkvs.push(pkv)
})
if (size(pkvs) > 0) {
//cmds, 分批以免IN清單過長超出Jet之查詢文字長度限制
let cmds = map(chunk(pkvs, nPkPerDelete), (vs) => {
return {
type: 'execute',
sql: getDeleteByPks(opt.cl, opt.pk, vs),
}
})
//run, 以交易包覆令任一批失敗時整體回滾
let r = await si.jet.run(cmds, { useTransaction: true })
//check
if (!r.ok) {
throw new Error(r.error)
}
//nDel
each(r.results, (rr) => {
nDel += isnum(rr.n) ? rr.n : 0
})
}
}
//res, 條件無命中時為0且屬正常結果
res = {
n: nDel,
nDeleted: nDel,
ok: 1,
}
}
catch (err) {
isErr = true
res = err
}
finally {
//closeSequelize
if (instance === null) { //內部自動初始化得close
await closeSequelize('delAll')
}
}
//check
if (isErr) {
emitError('delAll', null, res)
return Promise.reject(res)
}
//emit, 須於結果定案後發出
emitChange('delAll', null, res)
return res
}
/**
* 創建mdb資料庫檔案
*
* 註: 建出者為空庫,資料表需另建
*
* @memberOf WOrmMdb
* @returns {Promise} 回傳Promise,resolve回傳創建結果,reject回傳錯誤訊息
*/
async function createStorage() {
//check, 已存在不重建
if (fs.existsSync(opt.storage)) {
return 'existed'
}
//create, 以ADOX建立新Jet4 mdb檔, 有密碼則建立加密檔
let jet = jetOpen({ path: opt.storage, password: getDbPassword() })
try {
await jet.create()
}
catch (err) {
emitError('createStorage', null, err)
return Promise.reject(err)
}
return 'created'
}
/**
* 由資料表物件生成各資料表的models資料
*
* @memberOf WOrmMdb
* @param {String} [fd='./models'] 輸入models儲存的資料夾名稱字串,預設'./models'
* @param {Object} [tabs={}] 輸入各資料表物件,預設{}
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {String} [opt.type='js'] 輸入資料表類型字串,預設'js'
*/
function genModelsByTabs(...input) {
_genModelsByTabs(...input)
}
//bind
ee.createStorage = createStorage
ee.genModelsByTabs = genModelsByTabs
ee.init = initAdodb
if (opt.useStable) {
//用佇列(同時最大執行數1且先進先執行)處理高併發之情形
//若沒管控, access mdb於同一行程內併發操作有機會出現非預期錯誤
ee.select = function() {
return pmq(select, ...arguments)
}
ee.selectByPk = function() {
return pmq(selectByPk, ...arguments)
}
ee.insert = function() {
return pmq(insert, ...arguments)
}
ee.insertBulk = function() {
return pmq(insertBulk, ...arguments)
}
ee.save = function() {
return pmq(save, ...arguments)
}
ee.del = function() {
return pmq(del, ...arguments)
}
ee.delAll = function() {
return pmq(delAll, ...arguments)
}
}
else {
ee.select = select
ee.selectByPk = selectByPk
ee.insert = insert
ee.insertBulk = insertBulk
ee.save = save
ee.del = del
ee.delAll = delAll
}
return ee
}
export default WOrmMdb