auth

Main authentication module for my projects
git clone git://git.finwo.net/app/auth
Log | Files | Refs

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 }