WOrmPostgresql.mjs

import pg from 'pg'
import mongoSql from 'mongo-sql'
import size from 'lodash-es/size.js'
import get from 'lodash-es/get.js'
import map from 'lodash-es/map.js'
import keys from 'lodash-es/keys.js'
import filter from 'lodash-es/filter.js'
import omit from 'lodash-es/omit.js'
import trim from 'lodash-es/trim.js'
import isEqual from 'lodash-es/isEqual.js'
import cloneDeep from 'lodash-es/cloneDeep.js'
import isstr from 'wsemi/src/isstr.mjs'
import isestr from 'wsemi/src/isestr.mjs'
import isarr from 'wsemi/src/isarr.mjs'
import isearr from 'wsemi/src/isearr.mjs'
import iseobj from 'wsemi/src/iseobj.mjs'
import isnum from 'wsemi/src/isnum.mjs'
import isint from 'wsemi/src/isint.mjs'
import isbol from 'wsemi/src/isbol.mjs'
import isDate from 'wsemi/src/isDate.mjs'
import haskey from 'wsemi/src/haskey.mjs'
import evem from 'wsemi/src/evem.mjs'
import pmSeries from 'wsemi/src/pmSeries.mjs'


/**
 * 操作資料庫(PostgreSQL)
 *
 * 本套件之主鍵欄位由opt.pk指定,預設為time,select以外之五函數皆以此欄位認定主鍵。
 * 因主鍵得為承載業務語義之欄位(如時序資料之觀測時間),insert與save於輸入未帶有效主鍵值時不自動補值,
 * 一律以reject回報;del亦不補值,該筆回ok:0與err。
 *
 * @class
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.url='postgresql://127.0.0.1:5432'] 輸入連接資料庫字串,預設'postgresql://127.0.0.1:5432'
 * @param {String} [opt.db='worm'] 輸入使用資料庫名稱字串,預設'worm'
 * @param {String} [opt.cl='test'] 輸入使用資料表名稱字串,預設'test'
 * @param {String} [opt.pk='time'] 輸入主鍵欄位名字串,預設'time'
 * @param {Boolean} [opt.useCache=false] 輸入是否使用select快取,適用於單程序操作,預設false
 * @returns {Object} 回傳操作資料庫物件,各事件功能詳見說明
 */
function WOrmPostgresql(opt = {}) {

    //_cache
    let _cache = null

    //url
    let url = get(opt, 'url')
    if (!isestr(url)) {
        url = 'postgresql://user:password@127.0.0.1:5432'
    }

    //db
    let db = get(opt, 'db')
    if (!isestr(db)) {
        db = 'worm'
    }

    //cl
    let cl = get(opt, 'cl')
    if (!isestr(cl)) {
        cl = 'test'
    }

    //useCache
    let useCache = get(opt, 'useCache')
    if (!isbol(useCache)) {
        useCache = false
    }

    //pkName, 主鍵欄位名, select以外之五函數皆以此欄位認定主鍵, 預設為time
    let pkName = get(opt, 'pk')
    if (!isestr(pkName)) {
        pkName = 'time'
    }

    //checkPk, 判定主鍵值是否有效
    //因主鍵欄位得由呼叫端指定, 其型別隨欄位而異, 故此處僅要求有給值而不限定型別,
    //型別與欄位不符者由PostgreSQL回報, 各函數再依其規格處置
    function checkPk(pkv) {
        return isestr(pkv) || isnum(pkv) || isbol(pkv) || isDate(pkv)
    }

    //isErrPkType, 判定錯誤是否為[主鍵值型別與欄位不符]
    //22P02為invalid_text_representation, 22007為invalid_datetime_format
    function isErrPkType(err) {
        let code = get(err, 'code')
        return code === '22P02' || code === '22007'
    }

    //getValueType
    function getValueType(value) {
        if (isstr(value)) {
            let isoTimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z)?$/
            return isoTimeRegex.test(value) ? 'TIMESTAMPTZ' : 'TEXT'
        }
        else if (isnum(value)) {
            return isint(value) ? 'INTEGER' : 'DOUBLE PRECISION'
        }
        else if (isbol(value)) {
            return 'BOOLEAN'
        }
        else if (value === null) {
            return 'TEXT'
        }
        else {
            return 'JSONB'
        }
    }

    //genSqlForCreateTable
    function genSqlForCreateTable(tableName, pk, obj) {

        //check
        if (!iseobj(obj)) {
            throw new Error(`obj[${obj}] is not an effective object`)
        }

        //columns
        let columns = Object.entries(obj).map(([key, value]) => {
            let type = getValueType(value)
            let c = `  ${key} ${type}`
            if (key === pk) {
                c = `${c} PRIMARY KEY`
            }
            return c
        })

        //c
        let c = `CREATE TABLE ${tableName} (\n${columns.join(',\n')}\n);`
        // console.log('c', c)

        return c
    }

    //genConflictSQL
    function genConflictSQL(obj) {

        //allKeys
        let allKeys = keys(obj)

        //conflictKeys
        let conflictKeys = [pkName]

        //updateKeys
        let updateKeys = filter(allKeys, (k) => {
            return !conflictKeys.includes(k)
        })

        //check
        if (size(updateKeys) === 0) {
            return ''
        }

        //updateClause
        let updateClause = updateKeys
            .map(key => `${key} = EXCLUDED.${key}`)
            .join(',\n  ')

        //c
        let c = `
            ON CONFLICT (${conflictKeys.join(', ')}) DO UPDATE SET
                ${updateClause}
        `

        //trim
        c = trim(c)

        return c
    }

    //connectionString
    let connectionString = `${url}/${db}`
    // console.log('connectionString', connectionString)

    //ee, 採eventemitter3而不採Node內建之events.EventEmitter,
    //因後者於'error'無監聽者時會將錯誤直接拋出, 將使操作行為隨呼叫端是否註冊監聽而異
    let ee = evem()

    //getErrMsg, 取錯誤訊息字串, 供逐筆結果之err欄位與error事件使用
    let getErrMsg = (err) => {
        let m = get(err, 'message')
        if (isestr(m)) {
            return m
        }
        return String(err)
    }

    //emitChange, 資料實際異動成功後發出, 事件僅為附加通知不承擔回傳義務
    //訂閱函數拋錯不得影響本次操作, 故一律以try/catch包覆, 收斂於此以免逐處遺漏
    let emitChange = (mode, data, res) => {
        try {
            ee.emit('change', mode, data, res)
        }
        catch (errEmit) {
            console.log(errEmit)
        }
    }

    //emitError, 操作發生錯誤時發出, 錯誤訊息一律轉為字串
    //正常結果(如查無數據、主鍵未命中、內容相同而未寫入)不得發出, 以免監聽者將常態當異常
    let emitError = (mode, data, err) => {
        try {
            ee.emit('error', mode, data, getErrMsg(err))
        }
        catch (errEmit) {
            console.log(errEmit)
        }
    }

    //PgClient
    let PgClient = pg.Client
    // console.log('PgClient', PgClient)

    //clearCache
    function clearCache() {
        _cache = null
    }

    //getCacheKey
    function getCacheKey(find = {}, order = {}) {
        return JSON.stringify({
            find,
            order,
        })
    }

    //getCache
    function getCache(find = {}, order = {}) {
        if (iseobj(_cache)) {
            let key = getCacheKey(find, order)
            if (haskey(_cache, key)) {
                return cloneDeep(_cache[key]) //與外部使用數據脫勾
            }
        }
        return null
    }

    //setCache
    function setCache(find = {}, order = {}, data = []) {
        if (!iseobj(_cache)) {
            _cache = {}
        }
        let key = getCacheKey(find, order)
        _cache[key] = cloneDeep(data) //與外部使用數據脫勾
    }

    //insertBatches, 分批送出插入語句並以交易包覆, 回傳實際插入筆數, 供insert與insertBulk共用
    //PostgreSQL之協定以int16記綁定參數個數故上限為65535, 單一語句可送之筆數受欄位數所限,
    //超出者須分批送出; 分批後單語句之原子性已不足以維持[失敗即不留下部份寫入], 故一律以交易包覆,
    //令單批與分批之保證來源一致而不因批數而異
    //兩函數之衝突政策由useConflict區分, 語句各自獨立:
    //  true : 添加ON CONFLICT DO NOTHING, 已存在主鍵者跳過而不中斷整批, 為insert之語義
    //  false: 不添加, 任一筆撞主鍵之唯一約束即整句失敗且不寫入任何一筆, 為insertBulk之全有全無語義
    let insertBatches = async (client, data, useConflict, returnPk = false) => {

        //nCols, 取各筆欄位數之最大值
        let nCols = 1
        data.forEach(function(v) {
            nCols = Math.max(nCols, size(keys(v)))
        })

        //nPerBatch
        let nPerBatch = Math.max(1, Math.floor(65535 / nCols))

        //ltdata, 分批
        let ltdata = []
        for (let i = 0; i < size(data); i += nPerBatch) {
            ltdata.push(data.slice(i, i + nPerBatch))
        }

        //transaction
        await client.query('BEGIN')

        try {

            //nIns, rows
            let nIns = 0
            let rows = []

            //each batch
            for (let dt of ltdata) {

                //mr
                let mr = mongoSql.sql({
                    type: 'insert',
                    table: cl,
                    values: dt,
                })

                //sql
                let sql = mr.query
                if (useConflict) {
                    sql = `${sql} ON CONFLICT (${pkName}) DO NOTHING`
                }
                if (returnPk) {
                    //RETURNING僅回傳實際插入之列, 供逐筆結果映回輸入序
                    sql = `${sql} RETURNING ${pkName}`
                }

                //insert
                let r = await client.query(sql, mr.values)

                //nIns
                nIns += r.rowCount

                //rows, 分批時逐批收集再串接
                if (returnPk) {
                    rows = rows.concat(get(r, 'rows', []))
                }

            }

            //commit
            await client.query('COMMIT')

            return {
                nIns,
                rows,
            }
        }
        catch (err) {

            //rollback, 令已送出之批次一併回滾
            await client.query('ROLLBACK').catch(() => {})

            throw err
        }

    }

    //genInsertList, 依RETURNING所回傳之主鍵值映回輸入序, 產生與輸入等長且保序之逐筆結果
    //後端回傳之型別未必與輸入相同(如TIMESTAMPTZ欄位輸入字串而回傳Date物件), 直接比對必然落空,
    //故依回傳值之實際型別決定正規化方式, 且兩側共用同一函數
    //不採[一律嘗試解析為日期]之通用寫法, 因主鍵為文字欄位而值形如'2025'與'2025-01-01'者
    //將正規化為同值而誤判, 依實際型別分流則無此問題
    //同批含重複主鍵時RETURNING僅回傳一次, 故以首次出現者為已插入, 其餘視為已存在而跳過
    let genInsertList = (data, rows) => {

        //isDateCol, 以回傳值之實際型別判定
        let isDateCol = size(rows) > 0 && rows[0][pkName] instanceof Date

        //normPk
        let normPk = (v) => {
            if (isDateCol) {
                return new Date(v).toISOString()
            }
            return String(v)
        }

        //setIns, 實際插入之主鍵
        let setIns = new Set()
        rows.forEach(function(v) {
            setIns.add(normPk(get(v, pkName)))
        })

        //ltres
        let setSeen = new Set()
        return map(data, function(v) {

            //pkv
            let pkv = normPk(get(v, pkName))

            //inserted
            let inserted = setIns.has(pkv) && !setSeen.has(pkv)
            setSeen.add(pkv)

            return {
                n: 1,
                nInserted: inserted ? 1 : 0,
                ok: 1,
            }
        })
    }

    /**
     * 創建資料表
     * 註: pk未給時採建構時之opt.pk。因insert與save倚賴主鍵之唯一約束達成原子性,
     * 若給予與opt.pk不同之欄位,將建出其餘函數無法正確操作之資料表,故一般應留空或給予相同值
     *
     * @memberOf WOrmPostgresql
     * @param {String} cl 輸入資料表名字串
     * @param {String} [pk=opt.pk] 輸入主鍵欄位名字串,預設為建構時之opt.pk
     * @param {Array|Object} arr 輸入數據物件陣列或數據物件
     * @returns {Promise} 回傳Promise,resolve回傳成功訊息,reject回傳錯誤訊息
     */
    async function createTable(cl, pk, arr) {

        //pk, 未給時採建構時設定之主鍵欄位
        if (!isestr(pk)) {
            pk = pkName
        }

        let isErr = false
        let res = null

        //client
        let client = new PgClient({ connectionString })

        //connect
        try {

            //connect
            await client.connect()

        }
        catch (err) {
            isErr = true
            res = err
            client = null
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出
            emitError('createTable', arr, res)

            return Promise.reject(res)
        }

        try {

            //obj
            let obj = arr
            if (isearr(obj)) {
                obj = obj[0]
            }

            //check
            if (!iseobj(obj)) {
                throw new Error(`obj[${obj}] is not an effective object`)
            }

            //sql
            let sql = genSqlForCreateTable(cl, pk, obj)
            // console.log('sql', sql)

            //select
            await client.query(sql)
                .then(() => {
                    res = {
                        ok: 1,
                    }
                })
                .catch((err) => {
                    isErr = true
                    res = err.message
                })

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.end()
            client = null
        }
        // console.log('res', res)

        //update
        if (useCache) {
            clearCache()
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出
            emitError('createTable', arr, res)

            return Promise.reject(res)
        }

        return res
    }

    /**
     * 查詢數據
     *
     * @memberOf WOrmPostgresql
     * @param {Object} [find={}] 輸入查詢條件物件
     * @param {Object} [order={}] 輸入排序條件物件
     * @returns {Promise} 回傳Promise,resolve回傳數據,reject回傳錯誤訊息
     */
    async function select(find = {}, order = {}) {
        let isErr = false
        let res = null

        //cache
        if (useCache) {
            let cache = getCache(find, order)
            if (isarr(cache)) {
                return cache
            }
        }

        //client
        let client = new PgClient({ connectionString })

        //connect
        try {

            //connect
            await client.connect()

        }
        catch (err) {
            isErr = true
            res = err
            client = null
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出, 讀取函數無輸入數據故data為null
            emitError('select', null, res)

            return Promise.reject(res)
        }

        try {

            //mr
            let mr = mongoSql.sql({
                type: 'select',
                table: cl,
                where: find,
                // limit: 10,
                order,
            })
            // console.log('mr', mr)
            // console.log('mr.query', mr.query)
            // console.log('mr.values', mr.values)

            //select
            let r = await client.query(mr.query, mr.values)
            // console.log('r', r)

            //res
            res = get(r, 'rows')

            //check
            if (!isarr(res)) {
                isErr = true
                res = `can not select by find[${JSON.stringify(find)}]`
            }

            //cache
            if (useCache && !isErr) {
                setCache(find, order, res)
            }

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.end()
            client = null
        }
        // console.log('res', res)

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出, 讀取函數無輸入數據故data為null
            emitError('select', null, res)

            return Promise.reject(res)
        }

        return res
    }

    /**
     * 由主鍵查詢單筆數據,因直接由資料表主鍵索引取值,不需如select提取數據至前端再處理,故數據量大時效能較佳
     * 註: 本套件之主鍵欄位由建構時之opt.pk指定,預設為time
     *
     * 主鍵未命中或主鍵值無效皆回傳null而不reject,命中之判定基準與insert、save、del內對既有數據之認定一致。
     * 主鍵值之型別與欄位不符時PostgreSQL回報22P02或22007,亦屬主鍵值無效而回傳null。
     *
     * @memberOf WOrmPostgresql
     * @param {String|Number|Date} pk 輸入主鍵值,即數據之主鍵欄位值
     * @returns {Promise} 回傳Promise,resolve回傳數據物件,若無此主鍵則回傳null,reject回傳錯誤訊息
     */
    async function selectByPk(pk) {
        let isErr = false
        let res = null

        //check
        if (!checkPk(pk)) {
            //未給有效主鍵值視為查無數據, 判定基準與insert、save、del內對主鍵之認定一致
            return null
        }

        //find, 以建構時設定之主鍵欄位查找, 與insert、save、del、genConflictSQL之認定一致
        let find = {
            [pkName]: pk,
        }

        //cache, 與select共用快取, 因快取鍵值由find與order組成, 故此處等同select(find)之結果
        if (useCache) {
            let cache = getCache(find, {})
            if (isarr(cache)) {
                return get(cache, 0, null)
            }
        }

        //client
        let client = new PgClient({ connectionString })

        //connect
        try {

            //connect
            await client.connect()

        }
        catch (err) {
            isErr = true
            res = err
            client = null
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出, 讀取函數無輸入數據故data為null
            emitError('selectByPk', null, res)

            return Promise.reject(res)
        }

        try {

            //mr, 不使用limit以令結果與select(find)完全等價, 方能安全共用快取,
            //time為主鍵時本就僅回傳1筆, 不會因此多拉數據
            let mr = mongoSql.sql({
                type: 'select',
                table: cl,
                where: find,
            })
            // console.log('mr', mr)
            // console.log('mr.query', mr.query)
            // console.log('mr.values', mr.values)

            //select
            let r = await client.query(mr.query, mr.values)
            // console.log('r', r)

            //rows
            let rows = get(r, 'rows')

            //check
            if (!isarr(rows)) {
                throw new Error(`can not select by pk[${pk}]`)
            }

            //cache
            if (useCache) {
                setCache(find, {}, rows)
            }

            //v
            let v = get(rows, 0, null)

            //check, 判定基準與save、del內對既有數據之認定一致
            if (iseobj(v)) {
                res = v
            }
            else {
                //不存在主鍵, 回傳null
                res = null
            }

        }
        catch (err) {

            //check, 主鍵值之型別與欄位不符者亦屬主鍵值無效, 依規格回傳null而不reject
            if (isErrPkType(err)) {
                res = null
            }
            else {
                isErr = true
                res = err
            }

        }
        finally {
            await client.end()
            client = null
        }
        // console.log('res', res)

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出, 讀取函數無輸入數據故data為null
            //主鍵未命中與主鍵值無效皆為正常結果而不設isErr, 故不會誤發error
            emitError('selectByPk', null, res)

            return Promise.reject(res)
        }

        return res
    }

    /**
     * 插入數據,僅於主鍵不存在時寫入,已存在者跳過且不覆寫
     *
     * 預設回傳單一聚合物件,n為輸入筆數即本次嘗試插入之基準;nInserted為實際插入筆數,
     * 全數已存在而nInserted為0屬正常結果,不視為錯誤。同批含重複主鍵時僅首筆計入nInserted。
     * 因主鍵得承載業務語義,未帶有效主鍵值時不自動補值,一律以reject回報。
     *
     * option.returnList為true時改回傳與輸入等長且保序之逐筆陣列,令呼叫端得知[是哪幾筆]為新數據,
     * 而非僅知[有幾筆]。各元素之n恆為1、ok恆為1,因insert之任何錯誤皆屬整批性錯誤而reject,
     * 故逐筆元素不出現ok:0與err;同批含重複主鍵者以首次出現者之nInserted為1,其餘為0。
     * 兩種取值下之不變式為陣列長度等於輸入筆數,且nInserted為1者之筆數等於聚合模式之nInserted。
     *
     * @memberOf WOrmPostgresql
     * @param {Object|Array} data 輸入數據物件或陣列
     * @param {Object} [option={}] 輸入設定物件,預設為{}
     * @param {Boolean} [option.returnList=false] 輸入是否改回傳逐筆結果陣列,預設為false
     * @returns {Promise} 回傳Promise,resolve於returnList為false時回傳插入結果物件{n,nInserted,ok},
     * 為true時回傳逐筆結果陣列[{n,nInserted,ok}],reject回傳錯誤訊息
     */
    async function insert(data, option = {}) {
        let isErr = false
        let res = null

        //returnList
        let returnList = get(option, 'returnList', false)
        if (!isbol(returnList)) {
            returnList = false
        }

        //check
        //輸入無效時, returnList為true者回傳空陣列以對齊save與del之規定
        if (!iseobj(data) && !isearr(data)) {
            if (returnList) {
                return []
            }
            return {
                n: 0,
                nInserted: 0,
                ok: 1,
            }
        }

        //cloneDeep
        data = cloneDeep(data)

        //client
        let client = new PgClient({ connectionString })

        //connect
        try {

            //connect
            await client.connect()

        }
        catch (err) {
            isErr = true
            res = err
            client = null
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出
            emitError('insert', data, res)

            return Promise.reject(res)
        }

        try {

            //check
            if (!isarr(data)) {
                data = [data]
            }

            //check pk, 因主鍵得承載業務語義故不自動補值, 未帶有效主鍵值者屬整批性錯誤
            data = map(data, function(v, k) {
                if (!checkPk(get(v, pkName))) {
                    throw new Error(`invalid data[${k}].${pkName}[${get(v, pkName)}]`)
                }
                return v
            })

            //nAll
            let nAll = size(data)

            //insert, 以ON CONFLICT DO NOTHING令已存在主鍵者跳過而不中斷整批插入,
            //由PostgreSQL於單一語句內原子完成[檢查主鍵未存在]與[寫入], 併發時同一主鍵僅有一次成功,
            //同批含重複主鍵時亦僅首筆成功, 故不須逐筆插入即可取得實際插入筆數
            //筆數過多而超出綁定參數上限者由insertBatches分批送出, 主鍵認定與genConflictSQL一致
            //returnList為true時另附RETURNING, 以實際插入之主鍵映回輸入序而得逐筆結果
            let { nIns, rows } = await insertBatches(client, data, true, returnList)

            //res
            if (returnList) {
                res = genInsertList(data, rows)
            }
            else {
                res = {
                    n: nAll,
                    nInserted: nIns,
                    ok: 1,
                }
            }

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.end()
            client = null
        }

        //update
        if (useCache) {
            clearCache()
        }

        //emit
        if (!isErr) {
            emitChange('insert', data, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出
            emitError('insert', data, res)

            return Promise.reject(res)
        }

        return res
    }

    /**
     * 批次插入數據,全批視為一個單位,全部插入成功或一筆都不寫入
     *
     * 本函數非insert之加速版,兩者衝突政策不同:insert於主鍵已存在時跳過該筆且整批ok為1,
     * 本函數則於任一筆之主鍵已存在時整批reject且不寫入任何一筆,同批含重複主鍵者亦視為衝突。
     * n為輸入筆數,nInserted為實際插入筆數且成功時恆等於n。
     * 不提供逐筆結果,故不出現ok:0與err,需要逐筆處置者改用insert。
     * 因主鍵得承載業務語義,未帶有效主鍵值時不自動補值,一律以reject回報,
     * 且主鍵檢查於任何寫入之前一次完成,故整批reject時同批之有效筆數亦不會被寫入。
     *
     * @memberOf WOrmPostgresql
     * @param {Object|Array} data 輸入數據物件或陣列
     * @returns {Promise} 回傳Promise,resolve回傳插入結果物件{n,nInserted,ok},reject回傳錯誤訊息
     */
    async function insertBulk(data) {
        let isErr = false
        let res = null

        //check
        if (!iseobj(data) && !isearr(data)) {
            return {
                n: 0,
                nInserted: 0,
                ok: 1,
            }
        }

        //cloneDeep
        data = cloneDeep(data)

        //client
        let client = new PgClient({ connectionString })

        //connect
        try {

            //connect
            await client.connect()

        }
        catch (err) {
            isErr = true
            res = err
            client = null
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出
            emitError('insertBulk', data, res)

            return Promise.reject(res)
        }

        try {

            //check
            if (!isarr(data)) {
                data = [data]
            }

            //check pk, 因主鍵得承載業務語義故不自動補值, 未帶有效主鍵值者屬整批性錯誤
            data = map(data, function(v, k) {
                if (!checkPk(get(v, pkName))) {
                    throw new Error(`invalid data[${k}].${pkName}[${get(v, pkName)}]`)
                }
                return v
            })

            //nAll
            let nAll = size(data)

            //insertBulk, 不添加conflict, 令任一筆撞主鍵之唯一約束即整句失敗且不寫入任何一筆,
            //同批含重複主鍵者亦於此被偵測為衝突; 筆數過多者由insertBatches分批送出並以交易維持全有全無
            //不附RETURNING, 因成功時逐筆恆為已插入而nInserted恆等於n, 取回主鍵值無資訊量
            let { nIns } = await insertBatches(client, data, false)

            //res
            res = {
                n: nAll,
                nInserted: nIns,
                ok: 1,
            }

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.end()
            client = null
        }

        //update
        if (useCache) {
            clearCache()
        }

        //emit
        if (!isErr) {
            emitChange('insertBulk', data, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出
            emitError('insertBulk', data, res)

            return Promise.reject(res)
        }

        return res
    }

    /**
     * 儲存數據,以主鍵為準更新既有數據,未給之欄位保留
     *
     * 回傳陣列恆與輸入等長,輸入單一物件亦回傳長度1之陣列。
     * 各筆之n為主鍵命中筆數,值為0或1,命中(不論內容有無變更)或經插入而產生皆為1;
     * nInserted與nModified恆同時出現,無對應行為時填0。
     * [內容相同]之判定基準為待寫入物件合併進現值後與現值相同,非待寫入物件與現值全等,
     * 故只給部份欄位且該些欄位值皆與現值相同時,合併結果等於現值,nModified為0,
     * 令nModified忠實反映資料庫端是否真的寫入。
     * 因主鍵得承載業務語義,未帶有效主鍵值時不自動補值,一律以reject回報。
     *
     * @memberOf WOrmPostgresql
     * @param {Object|Array} data 輸入數據物件或陣列
     * @param {Object} [option={}] 輸入設定物件,預設為{}
     * @param {boolean} [option.autoInsert=true] 輸入是否於儲存時發現原本無數據,則自動改以插入處理,預設為true
     * @returns {Promise} 回傳Promise,resolve回傳儲存結果陣列[{n,nInserted,nModified,ok}],reject回傳錯誤訊息
     */
    async function save(data, option = {}) {
        let isErr = false
        let res = null

        //check
        if (!iseobj(data) && !isearr(data)) {
            return []
        }

        //cloneDeep
        data = cloneDeep(data)

        //autoInsert
        let autoInsert = get(option, 'autoInsert', true)

        //client
        let client = new PgClient({ connectionString })

        //connect
        try {

            //connect
            await client.connect()

        }
        catch (err) {
            isErr = true
            res = err
            client = null
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出
            emitError('save', data, res)

            return Promise.reject(res)
        }

        try {

            //check
            if (!isarr(data)) {
                data = [data]
            }

            //check pk, 因主鍵得承載業務語義故不自動補值, 未帶有效主鍵值者屬整批性錯誤
            data = map(data, function(v, k) {
                if (!checkPk(get(v, pkName))) {
                    throw new Error(`invalid data[${k}].${pkName}[${get(v, pkName)}]`)
                }
                return v
            })

            //pmSeries
            res = await pmSeries(data, async(v) => {

                //rest
                let rest = null

                //pkv
                let pkv = get(v, pkName)

                //_v
                let _v = null
                if (true) {

                    //mr
                    let mr = mongoSql.sql({
                        type: 'select',
                        table: cl,
                        where: {
                            [pkName]: pkv,
                        },
                        // limit: 10,
                        // order,
                    })
                    // console.log('mr', mr)
                    // console.log('mr.query', mr.query)
                    // console.log('mr.values', mr.values)

                    //select
                    let r = await client.query(mr.query, mr.values)
                    // console.log('r', r)

                    //rows
                    let rows = get(r, 'rows')
                    // console.log('rows', rows)

                    //_v
                    _v = get(rows, 0, null)
                    // console.log('_v', _v)

                }

                //快速路徑, 先不寫入直接由前述預讀判斷內容是否相同, 內容相同者本就不須寫入,
                //預讀值縱使已被其他寫入者更動而過期亦不影響正確性, 因內容相同時本次save等價於無操作,
                //無操作可視為於預讀當下即已完成, 其後他人之寫入結果與[本次save先執行再輪到他人]相同
                if (iseobj(_v)) {

                    //_vt
                    let _vt = omit(_v, pkName)
                    // console.log('_vt', _vt)

                    //vt, [內容相同]之判定基準為[待寫入物件合併進現值後與現值相同],
                    //非[待寫入物件與現值全等], 故只給部份欄位且該些欄位值皆與現值相同時視為相同而不寫入,
                    //合併採淺層覆蓋, 與後端以EXCLUDED整欄取代之寫入行為一致
                    let vt = {
                        ..._vt,
                        ...omit(v, pkName),
                    }
                    // console.log('vt', vt)

                    if (isEqual(vt, _vt)) {
                        //相同時不更新

                        //rest
                        rest = {
                            n: 1,
                            nInserted: 0,
                            nModified: 0,
                            ok: 1,
                        }
                        // console.log('相同時不更新', rest, v)

                        return rest
                    }

                }
                else {

                    //check
                    if (!autoInsert) {
                        //不存在且未開啟autoInsert則不寫入

                        //rest
                        rest = {
                            n: 0,
                            nInserted: 0,
                            nModified: 0,
                            ok: 1,
                        }
                        // console.log('不存在', rest, v)

                        return rest
                    }

                }

                //check
                if (autoInsert) {
                    //由PostgreSQL於單一語句內原子完成[查找time]與[插入或更新],
                    //併發時不會因他方已先插入同一time而報錯, 亦不會有兩方各自讀到同一舊值再各自寫入

                    //mr
                    let mr = mongoSql.sql({
                        type: 'insert',
                        table: cl,
                        values: v,
                    })
                    // console.log('mr', mr)
                    // console.log('mr.query', mr.query)
                    // console.log('mr.values', mr.values)

                    //添加conflict, 僅有主鍵欄位時genConflictSQL無可更新欄位而回傳空字串,
                    //此時退回DO NOTHING, 以免併發插入同一主鍵者報錯
                    let conflict = genConflictSQL(v)
                    if (!isestr(conflict)) {
                        conflict = `ON CONFLICT (${pkName}) DO NOTHING`
                    }

                    //sql, xmax為0表該列由本語句插入, 非0表係由DO UPDATE更新而來,
                    //衝突且落入DO NOTHING時不回傳任何列, 代表該time已存在且未變更
                    let sql = `${mr.query} ${conflict} RETURNING (xmax = 0) AS inserted`

                    //save
                    await client.query(sql, mr.values)
                        .then((r) => {

                            //rows
                            let rows = get(r, 'rows')

                            //check
                            if (!isearr(rows)) {
                                //衝突且未更新

                                //rest
                                rest = {
                                    n: 1,
                                    nInserted: 0,
                                    nModified: 0,
                                    ok: 1,
                                }

                            }
                            else if (get(rows, '0.inserted') === true) {
                                //原不存在而插入

                                //rest
                                rest = {
                                    n: 1,
                                    nInserted: 1,
                                    nModified: 0,
                                    ok: 1,
                                }
                                // console.log('之前不存在且可自動插入', rest, v)

                            }
                            else {
                                //原已存在而更新

                                //rest
                                rest = {
                                    n: 1,
                                    nInserted: 0,
                                    nModified: 1,
                                    ok: 1,
                                }
                                // console.log('不相同時須更新', rest, v)

                            }

                        })
                        .catch((err) => {

                            //rest
                            rest = {
                                n: 1,
                                nInserted: 0,
                                nModified: 0,
                                ok: 0,
                                err: err.message,
                            }

                        })

                }
                else {
                    //未開啟autoInsert則僅能更新既有數據, 不可用INSERT以免無中生有,
                    //由UPDATE...WHERE主鍵原子完成[查找主鍵]與[更新], 併發時不會遺失更新

                    //mr
                    let mr = mongoSql.sql({
                        type: 'update',
                        table: cl,
                        updates: omit(v, pkName),
                        where: {
                            [pkName]: pkv,
                        },
                    })
                    // console.log('mr', mr)
                    // console.log('mr.query', mr.query)
                    // console.log('mr.values', mr.values)

                    //save
                    await client.query(mr.query, mr.values)
                        .then((r) => {

                            //check
                            if (r.rowCount > 0) {
                                //已更新

                                //rest
                                rest = {
                                    n: 1,
                                    nInserted: 0,
                                    nModified: 1,
                                    ok: 1,
                                }
                                // console.log('不相同時須更新', rest, v)

                            }
                            else {
                                //預讀後至更新前已被他人刪除, 視為不存在

                                //rest
                                rest = {
                                    n: 0,
                                    nInserted: 0,
                                    nModified: 0,
                                    ok: 1,
                                }

                            }

                        })
                        .catch((err) => {

                            //rest
                            rest = {
                                n: 1,
                                nInserted: 0,
                                nModified: 0,
                                ok: 0,
                                err: err.message,
                            }

                        })

                }

                //emit, 該筆經插入而產生者另發mode為insert之change事件, 供呼叫端區辨新增與更新
                if (rest.nInserted === 1) {
                    emitChange('insert', [v], rest)
                }

                //emit, 逐筆失敗於該筆結果定案後發出, 每筆一次
                if (rest.ok === 0) {
                    emitError('save', [v], rest.err)
                }

                return rest
            })

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.end()
            client = null
        }

        //update
        if (useCache) {
            clearCache()
        }

        //emit
        if (!isErr) {
            emitChange('save', data, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出
            emitError('save', data, res)

            return Promise.reject(res)
        }

        return res
    }

    /**
     * 刪除數據
     *
     * 回傳陣列恆與輸入等長,各筆之n與nDeleted皆為主鍵命中筆數,值為0或1,未命中為0且屬正常結果。
     * 判斷某筆是否真的被刪除一律以nDeleted為準。
     * 未帶有效主鍵值者為該筆之輸入問題,回ok:0與err且不送查詢,不中斷其餘筆數,
     * 以此與[主鍵未命中]之ok:1區辨。
     *
     * @memberOf WOrmPostgresql
     * @param {Object|Array} data 輸入數據物件或陣列
     * @returns {Promise} 回傳Promise,resolve回傳刪除結果陣列[{n,nDeleted,ok}],reject回傳錯誤訊息
     */
    async function del(data) {
        let isErr = false
        let res = null

        //check
        if (!iseobj(data) && !isearr(data)) {
            return []
        }

        //cloneDeep
        data = cloneDeep(data)

        //client
        let client = new PgClient({ connectionString })

        //connect
        try {

            //connect
            await client.connect()

        }
        catch (err) {
            isErr = true
            res = err
            client = null
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出
            emitError('del', data, res)

            return Promise.reject(res)
        }

        try {

            // //database, collection
            // let database = client.db(opt.db)
            // let collection = database.collection(opt.cl)

            //check
            if (!isarr(data)) {
                data = [data]
            }

            //pmSeries
            res = await pmSeries(data, async(v) => {

                //rest
                let rest = null

                //pkv
                let pkv = get(v, pkName)

                //check pk, 未帶有效主鍵者不補值亦不送查詢,
                //因mongo-sql會將無效值轉為null而誤中其他數據, 故直接視為該筆無法處理
                if (!checkPk(pkv)) {

                    //rest
                    rest = {
                        n: 0,
                        nDeleted: 0,
                        ok: 0,
                        err: `invalid ${pkName}[${pkv}]`,
                    }

                    //emit, 逐筆失敗於該筆結果定案後發出, 每筆一次
                    emitError('del', [v], rest.err)

                    return rest
                }

                //mr
                let mr = mongoSql.sql({
                    type: 'delete',
                    table: cl,
                    where: {
                        [pkName]: pkv,
                    },
                })
                // console.log('mr', mr)
                // console.log('mr.query', mr.query)
                // console.log('mr.values', mr.values)

                //del, n與nDeleted皆為主鍵命中筆數, 未命中為0
                await client.query(mr.query, mr.values)
                    .then((r) => {

                        //res
                        rest = {
                            n: r.rowCount,
                            nDeleted: r.rowCount,
                            ok: 1,
                        }

                    })
                    .catch((err) => {

                        //rest
                        rest = {
                            n: 1,
                            nDeleted: 0,
                            ok: 0,
                            err: err.message,
                        }

                    })

                //emit, 逐筆失敗於該筆結果定案後發出, 每筆一次
                if (rest.ok === 0) {
                    emitError('del', [v], rest.err)
                }

                return rest
            })

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.end()
            client = null
        }

        //update
        if (useCache) {
            clearCache()
        }

        //emit
        if (!isErr) {
            emitChange('del', data, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出
            emitError('del', data, res)

            return Promise.reject(res)
        }

        return res
    }

    /**
     * 刪除全部數據,需與del分開,避免未傳數據導致直接刪除全表
     *
     * n與nDeleted皆為實際刪除筆數,不取全表筆數。find未給或為空物件時刪除全部數據,
     * 條件無命中時回{n:0,nDeleted:0,ok:1},不視為錯誤。
     *
     * @memberOf WOrmPostgresql
     * @param {Object} [find={}] 輸入刪除條件物件
     * @returns {Promise} 回傳Promise,resolve回傳刪除結果物件{n,nDeleted,ok},reject回傳錯誤訊息
     */
    async function delAll(find = {}) {
        let isErr = false
        let res = null

        //client
        let client = new PgClient({ connectionString })

        //connect
        try {

            //connect
            await client.connect()

        }
        catch (err) {
            isErr = true
            res = err
            client = null
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出, 無輸入數據故data為null
            emitError('delAll', null, res)

            return Promise.reject(res)
        }

        try {

            // //database, collection
            // let database = client.db(opt.db)
            // let collection = database.collection(opt.cl)

            // //deleteMany
            // res = await collection.deleteMany(find)

            // //res
            // res = {
            //     n: res.deletedCount,
            //     nDeleted: res.deletedCount,
            //     ok: res.acknowledged ? 1 : 0,
            // }

            //mr
            let mr = mongoSql.sql({
                type: 'delete',
                table: cl,
                where: find,
            })
            // console.log('mr', mr)
            // console.log('mr.query', mr.query)
            // console.log('mr.values', mr.values)

            //delAll
            await client.query(mr.query, mr.values)
                .then((r) => {

                    //res
                    res = {
                        n: r.rowCount,
                        nDeleted: r.rowCount,
                        ok: 1,
                    }

                })
                .catch((err) => {
                    isErr = true
                    res = err.message
                })

        }
        catch (err) {
            isErr = true
            res = err
        }
        finally {
            await client.end()
            client = null
        }

        //update
        if (useCache) {
            clearCache()
        }

        //emit
        if (!isErr) {
            emitChange('delAll', null, res)
        }

        //check
        if (isErr) {

            //emit, 整批性錯誤於reject之前發出
            emitError('delAll', null, res)

            return Promise.reject(res)
        }

        return res
    }

    //save
    ee.createTable = createTable
    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 WOrmPostgresql