query.js (5957B)
1 // src/lib/db/query.js - named placeholder compilation. 2 // 3 // {name} compiles to ? for sqlite and mysql, $n for postgres. Dotted names 4 // address nested values. Braces inside literals, identifiers and comments are 5 // left untouched; {{name}} emits a literal {name}. 6 7 import { normalizeValue } from './params.js'; 8 9 const NAME = /^\{([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\}/; 10 11 // Per dialect lexing rules. Everything here is about deciding what to skip. 12 export const RULES = { 13 sqlite: { hash: false, backtick: true, backslash: false, dollar: false, nestedBlock: false }, 14 mysql: { hash: true, backtick: true, backslash: true, dollar: false, nestedBlock: false }, 15 postgres: { hash: false, backtick: false, backslash: false, dollar: true, nestedBlock: true }, 16 }; 17 18 function placeholder(dialect, index) { 19 return dialect === 'postgres' ? `$${index}` : '?'; 20 } 21 22 export function compileQuery(sql, dialect) { 23 const rules = RULES[dialect]; 24 if (!rules) throw new Error(`unknown dialect: ${dialect}`); 25 26 let out = ''; 27 const keys = []; 28 let i = 0; 29 30 while (i < sql.length) { 31 const c = sql[i]; 32 const next = sql[i + 1]; 33 34 if ((c === '-' && next === '-') || (rules.hash && c === '#')) { 35 const nl = sql.indexOf('\n', i); 36 const end = nl === -1 ? sql.length : nl; 37 out += sql.slice(i, end); 38 i = end; 39 continue; 40 } 41 42 if (c === '/' && next === '*') { 43 let depth = 1; 44 let j = i + 2; 45 while (j < sql.length && depth > 0) { 46 if (rules.nestedBlock && sql[j] === '/' && sql[j + 1] === '*') { 47 depth += 1; 48 j += 2; 49 } else if (sql[j] === '*' && sql[j + 1] === '/') { 50 depth -= 1; 51 j += 2; 52 } else { 53 j += 1; 54 } 55 } 56 out += sql.slice(i, j); 57 i = j; 58 continue; 59 } 60 61 if (c === "'" || c === '"' || (rules.backtick && c === '`')) { 62 const quote = c; 63 let j = i + 1; 64 while (j < sql.length) { 65 if (rules.backslash && quote !== '`' && sql[j] === '\\') { 66 j += 2; 67 continue; 68 } 69 if (sql[j] === quote) { 70 // A doubled quote is an escaped quote, not a terminator. 71 if (sql[j + 1] === quote) { 72 j += 2; 73 continue; 74 } 75 j += 1; 76 break; 77 } 78 j += 1; 79 } 80 out += sql.slice(i, j); 81 i = j; 82 continue; 83 } 84 85 if (rules.dollar && c === '$') { 86 const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i)); 87 if (m) { 88 const tag = m[0]; 89 const end = sql.indexOf(tag, i + tag.length); 90 const stop = end === -1 ? sql.length : end + tag.length; 91 out += sql.slice(i, stop); 92 i = stop; 93 continue; 94 } 95 } 96 97 // Doubling escapes a brace, so {{name}} yields the literal text {name}. 98 if (c === '}' && next === '}') { 99 out += '}'; 100 i += 2; 101 continue; 102 } 103 104 if (c === '{') { 105 if (next === '{') { 106 out += '{'; 107 i += 2; 108 continue; 109 } 110 const m = NAME.exec(sql.slice(i)); 111 if (!m) { 112 throw new Error( 113 `malformed bind marker at offset ${i} in query:\n ${sql.trim()}\n` + 114 'Expected {name} or {outer.inner}; write {{ for a literal brace.' 115 ); 116 } 117 keys.push(m[1]); 118 out += placeholder(dialect, keys.length); 119 i += m[0].length; 120 continue; 121 } 122 123 out += c; 124 i += 1; 125 } 126 127 return { sql: out, keys }; 128 } 129 130 // Compilation is pure and queries come from source code, so the set is finite. 131 const cache = new Map(); 132 133 export function compileCached(sql, dialect) { 134 const cacheKey = `${dialect}\u0000${sql}`; 135 let entry = cache.get(cacheKey); 136 if (!entry) { 137 entry = compileQuery(sql, dialect); 138 cache.set(cacheKey, entry); 139 } 140 return entry; 141 } 142 143 // Builds the body of an IN (...) clause with generated marker names, since 144 // the number of values is only known at runtime. 145 // 146 // const arch = inClause('arch', ['x86_64', 'aarch64']); 147 // db.all(`... WHERE arch IN (${arch.sql})`, { ...arch.params }); 148 // 149 // Returns a clause that matches nothing when the list is empty, which is 150 // the safe reading of "none of these". 151 export function inClause(prefix, values) { 152 if (!Array.isArray(values) || values.length === 0) { 153 return { sql: 'NULL', params: {}, empty: true }; 154 } 155 const params = {}; 156 const markers = values.map((value, i) => { 157 params[`${prefix}${i}`] = value; 158 return `{${prefix}${i}}`; 159 }); 160 return { sql: markers.join(', '), params, empty: false }; 161 } 162 163 function resolve(params, key) { 164 // An exact property wins, so a pre-flattened object works unchanged. 165 if (params != null && Object.hasOwn(params, key)) { 166 return { found: true, value: params[key] }; 167 } 168 if (!key.includes('.')) return { found: false }; 169 170 let cur = params; 171 for (const part of key.split('.')) { 172 if (cur == null || typeof cur !== 'object') return { found: false }; 173 if (!Object.hasOwn(cur, part)) return { found: false }; 174 cur = cur[part]; 175 } 176 return { found: true, value: cur }; 177 } 178 179 export function bindParams(keys, params, sql) { 180 if (keys.length === 0) return []; 181 182 if (params == null || typeof params !== 'object' || Array.isArray(params)) { 183 throw new Error( 184 `bind parameters must be an object, got ${Array.isArray(params) ? 'an array' : typeof params}\n` + 185 ` query: ${sql.trim().split('\n')[0]}\n` + 186 ` expected keys: ${[...new Set(keys)].join(', ')}` 187 ); 188 } 189 190 const missing = []; 191 const values = keys.map((key) => { 192 const hit = resolve(params, key); 193 if (!hit.found) { 194 missing.push(key); 195 return null; 196 } 197 return normalizeValue(hit.value, key); 198 }); 199 200 if (missing.length > 0) { 201 throw new Error( 202 `missing bind parameter(s): ${[...new Set(missing)].join(', ')}\n` + 203 ` query: ${sql.trim().split('\n')[0]}\n` + 204 ` supplied: ${Object.keys(params).join(', ') || '(none)'}` 205 ); 206 } 207 208 return values; 209 }