conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 }