pf_app/lib/sql_generator.js
Paul Trowbridge cfee3e96b9 Return inserted rows from change operations for incremental grid updates
Instead of re-fetching all forecast data after scale/recode/clone/reference,
the routes now return the inserted rows directly. The frontend uses ag-Grid's
applyTransaction to add only the new rows, eliminating the full reload round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:04:28 -04:00

255 lines
8.9 KiB
JavaScript

// 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() {
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 ${selectData}, '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 };