// Generates operation SQL for a source table, baking in column names from col_meta. // Runtime values are left as {{token}} substitution points. // // Tokens baked in at generation time: column names, source schema.table // Tokens substituted at request time: {{fc_table}}, {{where_clause}}, {{exclude_clause}}, // {{version_id}}, {{logid}}, {{pf_user}}, {{note}}, // {{params}}, {{slice}}, {{date_from}}, {{date_to}}, // {{value_incr}}, {{units_incr}}, {{set_clause}}, {{scale_factor}} // wrap a column name in double quotes for safe use in SQL function q(name) { return `"${name}"`; } function generateSQL(source, colMeta) { const dims = colMeta .filter(c => c.role === 'dimension') .sort((a, b) => (a.opos || 0) - (b.opos || 0)) .map(c => c.cname); const valueCol = colMeta.find(c => c.role === 'value')?.cname; const unitsCol = colMeta.find(c => c.role === 'units')?.cname; const dateCol = colMeta.find(c => c.role === 'date')?.cname; if (!valueCol) throw new Error('No value column defined in col_meta'); if (!unitsCol) throw new Error('No units column defined in col_meta'); if (!dateCol) throw new Error('No date column defined in col_meta'); if (dims.length === 0) throw new Error('No dimension columns defined in col_meta'); const srcTable = `${source.schema}.${source.tname}`; // exclude 'id' — forecast table has its own bigserial id primary key const dataCols = [...dims, dateCol, valueCol, unitsCol].filter(c => c !== 'id'); const effectiveValue = dataCols.includes(valueCol) ? valueCol : null; const effectiveUnits = dataCols.includes(unitsCol) ? unitsCol : null; const insertCols = [...dataCols.map(q), 'iter', 'logid', 'pf_user', 'created_at'].join(', '); const selectData = dataCols.map(q).join(', '); const dimsJoined = dims.map(q).join(', '); return { get_data: buildGetData(), baseline: buildBaseline(), reference: buildReference(), scale: buildScale(), recode: buildRecode(), clone: buildClone(), undo: buildUndo() }; function buildGetData() { return `SELECT * FROM {{fc_table}}`; } function buildBaseline() { const baselineSelect = dataCols.map(c => c === dateCol ? `(${q(c)} + '{{date_offset}}'::interval)::date` : q(c) ).join(', '); return ` WITH ilog AS ( INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note) VALUES ({{version_id}}, '{{pf_user}}', 'baseline', NULL, '{{params}}'::jsonb, '{{note}}') RETURNING id ) ,del AS ( DELETE FROM {{fc_table}} WHERE iter = 'baseline' ) ,ins AS ( INSERT INTO {{fc_table}} (${insertCols}) SELECT ${baselineSelect}, 'baseline', (SELECT id FROM ilog), '{{pf_user}}', now() FROM ${srcTable} WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}' RETURNING * ) SELECT count(*) AS rows_affected FROM ins`.trim(); } function buildReference() { return ` WITH ilog AS ( INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note) VALUES ({{version_id}}, '{{pf_user}}', 'reference', NULL, '{{params}}'::jsonb, '{{note}}') RETURNING id ) ,ins AS ( INSERT INTO {{fc_table}} (${insertCols}) SELECT ${selectData}, 'reference', (SELECT id FROM ilog), '{{pf_user}}', now() FROM ${srcTable} WHERE ${q(dateCol)} BETWEEN '{{date_from}}' AND '{{date_to}}' RETURNING * ) SELECT * FROM ins`.trim(); } function buildScale() { const vSel = effectiveValue ? `round((${q(effectiveValue)} / NULLIF(total_value, 0)) * {{value_incr}}, 2)` : `0`; const uSel = effectiveUnits ? `round((${q(effectiveUnits)} / NULLIF(total_units, 0)) * {{units_incr}}, 5)` : `0`; const baseSelectParts = [ ...dimsJoined ? [dimsJoined] : [], q(dateCol), effectiveValue ? q(effectiveValue) : null, effectiveUnits ? q(effectiveUnits) : null, effectiveValue ? `sum(${q(effectiveValue)}) OVER () AS total_value` : null, effectiveUnits ? `sum(${q(effectiveUnits)}) OVER () AS total_units` : null ].filter(Boolean).join(',\n '); return ` WITH ilog AS ( INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note) VALUES ({{version_id}}, '{{pf_user}}', 'scale', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}') RETURNING id ) ,base AS ( SELECT ${baseSelectParts} FROM {{fc_table}} WHERE {{where_clause}} {{exclude_clause}} ) ,ins AS ( INSERT INTO {{fc_table}} (${insertCols}) SELECT ${[dimsJoined, q(dateCol), ...(effectiveValue ? [vSel] : []), ...(effectiveUnits ? [uSel] : [])].join(',\n ')}, 'scale', (SELECT id FROM ilog), '{{pf_user}}', now() FROM base RETURNING * ) SELECT * FROM ins`.trim(); } function buildRecode() { return ` WITH ilog AS ( INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note) VALUES ({{version_id}}, '{{pf_user}}', 'recode', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}') RETURNING id ) ,src AS ( SELECT ${selectData} FROM {{fc_table}} WHERE {{where_clause}} {{exclude_clause}} ) ,neg AS ( INSERT INTO {{fc_table}} (${insertCols}) SELECT ${dimsJoined}, ${q(dateCol)}, ${effectiveValue ? `-${q(effectiveValue)}` : '0'}, ${effectiveUnits ? `-${q(effectiveUnits)}` : '0'}, 'recode', (SELECT id FROM ilog), '{{pf_user}}', now() FROM src RETURNING * ) ,ins AS ( INSERT INTO {{fc_table}} (${insertCols}) SELECT {{set_clause}}, ${q(dateCol)}, ${effectiveValue ? q(effectiveValue) : '0'}, ${effectiveUnits ? q(effectiveUnits) : '0'}, 'recode', (SELECT id FROM ilog), '{{pf_user}}', now() FROM src RETURNING * ) SELECT * FROM neg UNION ALL SELECT * FROM ins`.trim(); } function buildClone() { return ` WITH ilog AS ( INSERT INTO pf.log (version_id, pf_user, operation, slice, params, note) VALUES ({{version_id}}, '{{pf_user}}', 'clone', '{{slice}}'::jsonb, '{{params}}'::jsonb, '{{note}}') RETURNING id ) ,ins AS ( INSERT INTO {{fc_table}} (${insertCols}) SELECT {{set_clause}}, ${q(dateCol)}, ${effectiveValue ? `round(${q(effectiveValue)} * {{scale_factor}}, 2)` : '0'}, ${effectiveUnits ? `round(${q(effectiveUnits)} * {{scale_factor}}, 5)` : '0'}, 'clone', (SELECT id FROM ilog), '{{pf_user}}', now() FROM {{fc_table}} WHERE {{where_clause}} {{exclude_clause}} RETURNING * ) SELECT * FROM ins`.trim(); } function buildUndo() { // undo is executed as two separate queries in the route handler // (delete from fc_table first, then delete from pf.log) to avoid // FK constraint ordering issues within a single CTE statement. // This entry is a placeholder — the undo route uses it as a template reference. return ` -- step 1 (run first): DELETE FROM {{fc_table}} WHERE logid = {{logid}}; -- step 2 (run after step 1): DELETE FROM pf.log WHERE id = {{logid}};`.trim(); } } // substitute {{token}} placeholders in a SQL string function applyTokens(sql, tokens) { let result = sql; for (const [key, value] of Object.entries(tokens)) { result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value ?? ''); } return result; } // build a SQL WHERE clause string from a slice object // validates all keys against the allowed dimension column list function buildWhere(slice, dimCols) { if (!slice || Object.keys(slice).length === 0) return 'TRUE'; const allowed = new Set(dimCols); const parts = []; for (const [col, val] of Object.entries(slice)) { if (!allowed.has(col)) { throw new Error(`"${col}" is not a dimension column`); } if (Array.isArray(val)) { const escaped = val.map(v => esc(v)); parts.push(`"${col}" IN ('${escaped.join("', '")}')`); } else { parts.push(`"${col}" = '${esc(val)}'`); } } return parts.join('\nAND '); } // build AND iter NOT IN (...) from a version's exclude_iters array function buildExcludeClause(excludeIters) { if (!excludeIters || excludeIters.length === 0) return ''; const list = excludeIters.map(i => `'${esc(i)}'`).join(', '); return `AND iter NOT IN (${list})`; } // build the dimension columns portion of a SELECT for recode/clone // replaces named dimensions with literal values, passes others through unchanged function buildSetClause(dimCols, setObj) { return dimCols.map(col => { if (setObj && setObj[col] !== undefined) { return `'${esc(setObj[col])}' AS "${col}"`; } return `"${col}"`; }).join(', '); } // escape a value for safe SQL string substitution function esc(val) { if (val === null || val === undefined) return ''; return String(val).replace(/'/g, "''"); } module.exports = { generateSQL, applyTokens, buildWhere, buildExcludeClause, buildSetClause, esc };