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