lowerFind.mjs

//lowerFind: cvOrmFind正規化結果 -> 方言中立之條件中間結構(IR), SQL族後端共用之lowering, 單一擁有者. 規則見CLAUDE_rulebook.md R-LOWER-01.
//本檔為可抽出之共用層(契約即本檔頭): 純JS, 零import, 不含任何資料庫/建構器/方言專屬名稱或假設. 自首個採用之w-orm系列後端移入(行為不變, 移入沿革見帳本).
//方言差異(欄名檢核、綁定參數上限、布林字面值、恆假寫法、參數化與引用方式)一律由呼叫端以opt或發射器承擔, 不進本檔.
//契約案例表(節點語意期望表: find -> IR -> 命中集合)見src/_lowerFindCases.mjs, IR快照(共用案例表之接受案 -> IR)見src/_lowerFindSnapshots.mjs.
//
//輸入: wsemi cvOrmFind回傳之全新null原型深複本(條件物件/運算子物件為null原型, 清單為密集真陣列, 純量為string/有限number/boolean/有效Date/null).
//  前提: 輸入須為cvOrmFind之複本; 非複本之輸入不保證節點形狀(如空$and得零項and節點、空運算子物件被略過而視同無條件; 縱深防禦僅限白名單外運算子), 此類輸入經cvOrmFind不可達.
//輸出: IR, 全新null原型節點; null表示[全部], 即無條件. 節點只有下列六種(白名單, 其餘一律throw):
//  { t:'and', a:[Node...] } { t:'or', a:[Node...] }
//      n元節點, a至少2項: 單項已展開, 同型(and內含and, or內含or)已攤平, 零項不產生. 深度/寬度上限與平衡二分等重新分組由發射器自行處理, 本檔不因方言上限限制寬度.
//  { t:'cmp', c, op, v, p }   op: eq ne gt gte lt lte    v: string | number | Date(非null, 非boolean)
//  { t:'bool', c, op, v, p }  op同上                      v: boolean. 布林之各方言字面值不同, 故獨立成型; 語意為SQL三值: NULL列不命中任何布林比較(含ne)
//  { t:'null', c, not, p }    IS [NOT] NULL
//  { t:'in', c, vs, not, p }  vs: 純量陣列(元素可為boolean; 不含null). not=false且vs為空 = 恆假(不產生綁定參數)
//      恆假只有一種語意: 結果集為空. 發射器是否引用欄位(並因此回報不存在之欄名)自決, 但不得影響結果集. not=true且vs為空不產生(上游已拒收).
//  c: 欄名(已通過opt.checkColumn). p: 來源路徑(如find.$or[0].age.$gt), 供後續映射遍回報invalid find: <原因> at [<路徑>]; p僅供診斷, 不參與語意(快取鍵須忽略).
//
//語意(SQL族, 依dspec〈一之三〉): { c: null } 與 $eq:null -> null; $ne:null -> null not; 其餘$ne/$nin為三值邏輯(不命中NULL列);
//  同欄多運算子與多欄鍵為隱含AND(攤平); $and/$or -> and/or; 單項and/or展開為該項.
//
//opt.maxParams: 必填; 單一語句綁定參數上限(由後端依自身協定傳入; 不限者傳Infinity), 核心不預設任何上限.
//  計數契約: cmp計1, in每元素計1, bool/null/空in計0; 超過即throw(以values.length對拍).
//opt.checkColumn(name): 方言專屬欄名檢核, 回傳''表通過, 其餘為拒收原因(不回顯值以外之內容)
//opt讀取: 以屬性存取讀opt自有可列舉之maxParams、checkColumn(自有存取器會被呼叫; 繼承值與不可列舉屬性不生效); opt為呼叫端之程式設定, 非輸入資料, 本函數不防護opt自身之存取器.
//值域與比較語意(欄位型別不在IR內、數值格式字串之轉換、Date毫秒精度、字串順序依collation)與方言端承擔項, 見src/_lowerFindCases.mjs檔頭.
//錯誤: throw Error('invalid find: <原因> at [<路徑>]'), 與cvOrmFind同格式(不回顯值)
//
//本檔所有輸出皆不使用push/索引指派建陣列(會呼叫Array.prototype上之數字索引setter, 令元素被吞掉而條件變寬), 一律以defineProperty附加; 節點為null原型.

let isDate = (v) => {
    try {
        Date.prototype.getTime.call(v)
        return true
    }
    catch (err) {
        return false
    }
}
let isObj = (v) => v !== null && typeof v === 'object'
let clean = (s, n) => {
    // eslint-disable-next-line no-control-regex
    let t = String(s).replace(/[\u0000-\u001f\u007f]/g, '?')
    return t.length > n ? `${t.slice(0, n)}...` : t
}
let fail = (msg, path) => {
    let p = clean(path, 400)
    p = p.length > 120 ? `...${p.slice(-100)}` : p
    throw new Error(`invalid find: ${clean(msg, 200)} at [${p}]`)
}
let nul = () => Object.create(null)
let put = (arr, v) => {
    Object.defineProperty(arr, arr.length, { __proto__: null, value: v, enumerable: true, writable: true, configurable: true })
}
let node = (o) => Object.assign(nul(), o)
let own = (o, k) => (isObj(o) && Object.prototype.propertyIsEnumerable.call(o, k) ? o[k] : undefined)

let CMP = Object.assign(Object.create(null), { $eq: 'eq', $ne: 'ne', $gt: 'gt', $gte: 'gte', $lt: 'lt', $lte: 'lte' })

let spend = (st, path, idx) => {
    st.params += 1
    if (st.params > st.maxParams) {
        fail(`too many bound parameters (> ${st.maxParams})`, idx === undefined ? path : `${path}[${idx}]`)
    }
}

let scalar = (c, op, v, p, st) => {
    if (typeof v === 'boolean') {
        return node({ t: 'bool', c, op, v, p })
    }
    spend(st, p)
    return node({ t: 'cmp', c, op, v, p })
}

//lowerField: 單一欄位條件 -> 節點(同欄多運算子為AND, 呼叫端攤平)
let lowerField = (c, v, path, st, out) => {
    if (v === null) {
        put(out, node({ t: 'null', c, not: false, p: path }))
        return
    }
    if (!isObj(v) || isDate(v)) {
        put(out, scalar(c, 'eq', v, path, st))
        return
    }
    let ks = Object.keys(v)
    for (let i = 0; i < ks.length; i++) {
        let op = ks[i]
        let x = v[op]
        let p = `${path}.${clean(op, 30)}`
        if (op === '$eq' || op === '$ne') {
            if (x === null) {
                put(out, node({ t: 'null', c, not: op === '$ne', p }))
            }
            else {
                put(out, scalar(c, CMP[op], x, p, st))
            }
        }
        else if (op === '$gt' || op === '$gte' || op === '$lt' || op === '$lte') {
            put(out, scalar(c, CMP[op], x, p, st))
        }
        else if (op === '$in' || op === '$nin') {
            let vs = []
            let n = x.length
            for (let j = 0; j < n; j++) {
                spend(st, p, j)
                put(vs, x[j])
            }
            put(out, node({ t: 'in', c, vs, not: op === '$nin', p }))
        }
        else {
            fail(`unsupported operator [${clean(op, 30)}]`, p) //cvOrmFind已拒收其餘運算子, 縱深防禦
        }
    }
}

//group: 單項展開; 多項同型(and內含and)就地攤平
let group = (t, items) => {
    if (items.length === 1) {
        return items[0]
    }
    let a = []
    for (let i = 0; i < items.length; i++) {
        let it = items[i]
        if (it.t === t) {
            for (let j = 0; j < it.a.length; j++) {
                put(a, it.a[j])
            }
        }
        else {
            put(a, it)
        }
    }
    return node({ t, a })
}

//lowerDoc: 條件物件 -> 節點陣列(隱含AND)
let lowerDoc = (doc, path, st, out) => {
    let ks = Object.keys(doc)
    for (let i = 0; i < ks.length; i++) {
        let k = ks[i]
        let v = doc[k]
        let p = `${path}.${clean(k, 30)}`
        if (k === '$and' || k === '$or') {
            let items = []
            for (let j = 0; j < v.length; j++) {
                let sub = []
                lowerDoc(v[j], `${p}[${j}]`, st, sub)
                put(items, group('and', sub))
            }
            put(out, group(k === '$and' ? 'and' : 'or', items))
        }
        else {
            let bad = st.checkColumn(k)
            if (bad) {
                fail(bad, p)
            }
            lowerField(k, v, p, st, out)
        }
    }
}


/**
 * w-orm系列條件(find)之方言中立IR(中間結構)轉換,SQL族後端共用之lowering
 *
 * 輸入為cvOrmFind回傳之全新null原型深複本,輸出為全新null原型之節點樹(IR),無條件回傳null;本函數只做語意轉譯,不檢核形狀(形狀檢核由cvOrmFind擁有;輸入須為其複本,非複本之輸入不保證節點形狀),方言差異(欄名檢核、綁定參數上限、布林字面值、恆假寫法、參數化與引用方式)由呼叫端以opt或各後端自有之發射器承擔。契約案例表見wsemi/src/_lowerFindCases.mjs,IR快照見wsemi/src/_lowerFindSnapshots.mjs。
 *
 * 1. IR節點只有六種:and、or(n元,a至少2項,單項已展開、同型已攤平、零項不產生;不因方言上限限制寬度,重新分組由發射器處理);cmp(c, op, v, p,op為eq ne gt gte lt lte,v為字串、有限數字或Date);bool(同cmp但v為布林,語意為SQL三值,NULL列不命中任何布林比較含ne);null(c, not, p,IS [NOT] NULL);in(c, vs, not, p,vs為純量陣列且不含null,not為false且vs為空代表恆假,結果集為空,發射器是否引用欄位自決,不得影響結果集)。c為欄名,p為葉節點之來源路徑(如find.$or[0].age.$gt),僅供診斷與後續映射遍回報invalid find,不參與語意(快取鍵須忽略)。
 *
 * 2. 語意(SQL族):{ 欄: null }與$eq:null為null節點,$ne:null為null not節點,其餘$ne與$nin為三值邏輯(不命中NULL列),同欄多運算子與多欄鍵為隱含AND,$and/$or為and/or,單項and/or展開為該項。
 *
 * 3. opt.maxParams必填(不限者傳Infinity,核心不預設任何方言上限),計數契約為cmp計1、in每元素計1、bool與null與空in計0,超過即拋錯,路徑指向越限處;opt.checkColumn(name)為方言專屬欄名檢核,回傳空字串表通過,其餘為拒收原因,僅對欄名鍵呼叫,非函數時視為全過。opt以屬性存取讀取自有可列舉屬性(自有存取器會被呼叫;繼承值與不可列舉屬性不生效),opt為呼叫端之程式設定而非輸入資料,不防護opt自身之存取器。
 *
 * 4. 所有輸出節點為null原型,陣列以defineProperty附加(不用push與索引指派),不受Array.prototype數字索引setter與Object.prototype汙染影響;純JS,無任何import。
 *
 * 錯誤一律為同步拋出之Error('invalid find: 原因 at [路徑]'),與cvOrmFind同格式,不回顯值;opt.maxParams缺漏或非法拋Error('lowerFind: opt.maxParams is required (use Infinity for no limit)')。
 *
 * Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/lowerFind.test.mjs Github}
 * @memberOf wsemi
 * @param {Object} copy 輸入cvOrmFind回傳之複本物件,無自有鍵代表無條件
 * @param {Object} opt 輸入設定物件
 * @param {Number} opt.maxParams 輸入單一語句綁定參數上限正數,不限者傳Infinity,必填
 * @param {Function} [opt.checkColumn] 輸入欄名檢核函數,傳入欄名字串,回傳空字串表通過,其餘為拒收原因字串,預設全過
 * @returns {Object|null} 回傳IR根節點(null原型),無條件回傳null
 * @throws {Error} 越限或欄名不合者拋出Error('invalid find: 原因 at [路徑]'),opt.maxParams缺漏者拋出Error('lowerFind: opt.maxParams is required (use Infinity for no limit)')
 * @example
 *
 * console.log(lowerFind(cvOrmFind({}), { maxParams: 100 }))
 * // => null
 *
 * let ir = lowerFind(cvOrmFind({ age: { $gt: 20 }, $or: [{ g: 'A' }, { g: null }] }), { maxParams: 100 })
 * console.log(JSON.stringify(ir))
 * // => {"t":"and","a":[{"t":"cmp","c":"age","op":"gt","v":20,"p":"find.age.$gt"},{"t":"or","a":[{"t":"cmp","c":"g","op":"eq","v":"A","p":"find.$or[0].g"},{"t":"null","c":"g","not":false,"p":"find.$or[1].g"}]}]}
 *
 * console.log(JSON.stringify(lowerFind(cvOrmFind({ n: { $in: [] }, b: true }), { maxParams: 100 })))
 * // => {"t":"and","a":[{"t":"in","c":"n","vs":[],"not":false,"p":"find.n.$in"},{"t":"bool","c":"b","op":"eq","v":true,"p":"find.b"}]}
 *
 * try {
 *     lowerFind(cvOrmFind({ a: { $in: [1, 2, 3] } }), { maxParams: 2 })
 * }
 * catch (err) {
 *     console.log(err.message)
 * }
 * // => invalid find: too many bound parameters (> 2) at [find.a.$in[2]]
 *
 */
function lowerFind(copy, opt) {
    let maxParams = own(opt, 'maxParams')
    if (typeof maxParams !== 'number' || !(maxParams > 0)) {
        throw new Error('lowerFind: opt.maxParams is required (use Infinity for no limit)')
    }
    let checkColumn = own(opt, 'checkColumn')
    let st = {
        params: 0,
        maxParams, //必填: 核心不預設任何方言之上限
        checkColumn: typeof checkColumn === 'function' ? checkColumn : () => '',
    }
    let out = []
    lowerDoc(copy, 'find', st, out)
    if (out.length === 0) {
        return null
    }
    return group('and', out)
}


export default lowerFind