pf_app/routes/sources.js
Paul Trowbridge 654a368672 Add static display-grain pre-aggregation (col_meta.in_grain)
Ship rows pre-aggregated to the grain the pivot displays instead of raw
forecast rows. This is Path B from pf_perspective_options.md: it keeps
Perspective's native WASM engine — so expand/collapse/depth/sort/filter
all still work — and fixes load time by cutting rows, not transport.

Measured on pf.fc_osm_stack_20 at pending_rep x customer x smon:
534,902 -> 6,154 rows (~87x), pf_gkey unique across all 6,154, and both
measures reconcile exactly to the raw totals.

The grain is static: flagged once per source in Setup and baked into the
stored pf.sql templates, so load and operations agree by construction.
Sources with no flagged column keep the previous raw-row behaviour, so
this is backward compatible.

- pf.col_meta gains in_grain; grainOf() in lib/sql_generator.js is the
  single definition of the grain and is reused by routes/log.js.
- New get_agg template + GET /api/versions/:id/agg, generated only when a
  grain is defined. Regenerating drops templates no longer produced, so
  clearing the grain falls back to /data.
- scale/recode/clone now aggregate their own new rows to grain before
  returning. Because pf_logid is part of pf_gkey those keys are always
  new, so table.update() appends and the view re-sums — the Excel
  pivot-cache pattern, no bucket recomputation.
- Undo reports pf_gkeys (RETURNING cannot take DISTINCT, so the delete
  feeds a CTE that reduces to distinct keys); the client removes those
  index values and the view re-sums.
- pf_gkey is concat_ws(chr(31), COALESCE(col::text, chr(30)), ...).
  The separator and NULL sentinel are load-bearing: plain concat_ws skips
  NULLs, so ('a',NULL) and (NULL,'a') would collide and silently merge two
  groups into one indexed row.
- Forecast.jsx reads col_meta first to pick /agg vs /data; the Arrow
  streaming logic is extracted to fetchArrow() since both share it.
- Setup.jsx gains a grain checkbox and shows the resulting grain.
- 01_schema.sql: move the col_meta ALTERs after its CREATE TABLE — they
  referenced the table before it existed on a fresh install.

All six generated statements verified to plan against the real forecast
table; the in_grain column has been added to the dev database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:25:38 -04:00

329 lines
13 KiB
JavaScript

const express = require('express');
const { generateSQL } = require('../lib/sql_generator');
module.exports = function(pool) {
const router = express.Router();
// list all registered sources
router.get('/sources', async (req, res) => {
try {
const result = await pool.query(
`SELECT * FROM pf.source ORDER BY schema, tname`
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// register a source table
// auto-populates col_meta from information_schema with role='ignore'
router.post('/sources', async (req, res) => {
const { schema, tname, label, created_by } = req.body;
if (!schema || !tname) {
return res.status(400).json({ error: 'schema and tname are required' });
}
if (!/^\w+$/.test(schema) || !/^\w+$/.test(tname)) {
return res.status(400).json({ error: 'Invalid schema or table name' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const src = await client.query(
`INSERT INTO pf.source (schema, tname, label, created_by)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[schema, tname, label || null, created_by || null]
);
const source = src.rows[0];
// seed col_meta from information_schema
await client.query(`
INSERT INTO pf.col_meta (source_id, cname, role, opos)
SELECT $1, column_name, 'dimension', ordinal_position
FROM information_schema.columns
WHERE table_schema = $2 AND table_name = $3
ORDER BY ordinal_position
ON CONFLICT (source_id, cname) DO NOTHING
`, [source.id, schema, tname]);
await client.query('COMMIT');
res.status(201).json(source);
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
if (err.code === '23505') {
return res.status(409).json({ error: 'Source already registered' });
}
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// get col_meta for a source
router.get('/sources/:id/cols', async (req, res) => {
try {
const result = await pool.query(
`SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`,
[req.params.id]
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// save col_meta — accepts full array, upserts each row
router.put('/sources/:id/cols', async (req, res) => {
const sourceId = parseInt(req.params.id);
const cols = req.body;
if (!Array.isArray(cols)) {
return res.status(400).json({ error: 'body must be an array' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const col of cols) {
await client.query(`
INSERT INTO pf.col_meta (source_id, cname, label, role, is_key, dim_group, dim_period_col, in_grain, opos)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (source_id, cname) DO UPDATE SET
label = EXCLUDED.label,
role = EXCLUDED.role,
is_key = EXCLUDED.is_key,
dim_group = EXCLUDED.dim_group,
dim_period_col = EXCLUDED.dim_period_col,
in_grain = EXCLUDED.in_grain,
opos = EXCLUDED.opos
`, [
sourceId,
col.cname,
col.label || null,
col.role || 'ignore',
col.is_key || false,
col.dim_group || null,
col.dim_period_col || null,
col.in_grain || false,
col.opos || null
]);
}
await client.query('COMMIT');
const result = await pool.query(
`SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`,
[sourceId]
);
res.json(result.rows);
} catch (err) {
await client.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: err.message });
} finally {
client.release();
}
});
// generate SQL for all operations from current col_meta and store in pf.sql
router.post('/sources/:id/generate-sql', async (req, res) => {
const sourceId = parseInt(req.params.id);
try {
const srcResult = await pool.query(
`SELECT * FROM pf.source WHERE id = $1`, [sourceId]
);
if (srcResult.rows.length === 0) {
return res.status(404).json({ error: 'Source not found' });
}
const colResult = await pool.query(
`SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`,
[sourceId]
);
// validate required roles
const colMeta = colResult.rows;
const roles = new Set(colMeta.map(c => c.role));
const missing = ['value', 'date'].filter(r => !roles.has(r));
if (missing.length > 0) {
return res.status(400).json({
error: `col_meta is missing required roles: ${missing.join(', ')}`
});
}
if (!colMeta.some(c => c.role === 'dimension')) {
return res.status(400).json({ error: 'col_meta has no dimension columns' });
}
const sqls = generateSQL(srcResult.rows[0], colMeta);
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const [operation, sql] of Object.entries(sqls)) {
await client.query(`
INSERT INTO pf.sql (source_id, operation, sql, generated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (source_id, operation) DO UPDATE SET
sql = EXCLUDED.sql,
generated_at = EXCLUDED.generated_at
`, [sourceId, operation, sql]);
}
// drop operations this generation no longer produces — e.g. get_agg
// after the grain has been cleared, which would otherwise leave a
// stale template the load path would still pick up
await client.query(
`DELETE FROM pf.sql WHERE source_id = $1 AND operation <> ALL($2::text[])`,
[sourceId, Object.keys(sqls)]
);
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
res.json({ message: 'SQL generated', operations: Object.keys(sqls) });
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// view generated SQL for a source (for inspection / debug)
router.get('/sources/:id/sql', async (req, res) => {
try {
const result = await pool.query(
`SELECT operation, sql, generated_at
FROM pf.sql WHERE source_id = $1 ORDER BY operation`,
[req.params.id]
);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// get distinct values for a key column (used to populate operation panel dropdowns)
router.get('/sources/:id/values/:col', async (req, res) => {
const col = req.params.col;
try {
const srcResult = await pool.query(
`SELECT schema, tname FROM pf.source WHERE id = $1`, [req.params.id]
);
if (srcResult.rows.length === 0) return res.status(404).json({ error: 'Source not found' });
// validate col is a key dimension on this source
const metaResult = await pool.query(
`SELECT 1 FROM pf.col_meta WHERE source_id = $1 AND cname = $2 AND is_key = true`,
[req.params.id, col]
);
if (metaResult.rows.length === 0) {
return res.status(400).json({ error: `"${col}" is not a key column` });
}
const { schema, tname } = srcResult.rows[0];
const result = await pool.query(
`SELECT DISTINCT "${col}" AS val FROM ${schema}.${tname}
WHERE "${col}" IS NOT NULL ORDER BY "${col}"`
);
res.json(result.rows.map(r => r.val));
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// given a key column value, look up sibling dim_group column values from source
// returns { sibling_col: value, ... } if exactly one match, null if none or ambiguous
router.get('/sources/:id/lookup', async (req, res) => {
const { col, value } = req.query;
if (!col || value == null || value === '') return res.json(null);
try {
const [srcResult, metaResult] = await Promise.all([
pool.query(`SELECT schema, tname FROM pf.source WHERE id = $1`, [req.params.id]),
pool.query(`SELECT * FROM pf.col_meta WHERE source_id = $1 ORDER BY opos`, [req.params.id])
]);
if (srcResult.rows.length === 0) return res.status(404).json({ error: 'Source not found' });
const keyCol = metaResult.rows.find(c => c.cname === col && c.is_key && c.dim_group);
if (!keyCol) return res.json(null);
const siblings = metaResult.rows.filter(c =>
c.dim_group === keyCol.dim_group && c.cname !== col
);
if (!siblings.length) return res.json(null);
const { schema, tname } = srcResult.rows[0];
const sibCols = siblings.map(c => `"${c.cname}"`).join(', ');
const result = await pool.query(
`SELECT DISTINCT ${sibCols} FROM "${schema}"."${tname}" WHERE "${col}" = $1 LIMIT 2`,
[value]
);
if (result.rows.length !== 1) return res.json(null);
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// set or clear the default Perspective layout for a source.
// Body: a Perspective view config (group_by, split_by, columns, plugin_config, …).
// Pass null or {} to clear.
router.put('/sources/:id/default-layout', async (req, res) => {
try {
const layout = req.body && Object.keys(req.body).length > 0 ? req.body : null;
const result = await pool.query(
`UPDATE pf.source SET default_layout = $1 WHERE id = $2 RETURNING *`,
[layout, req.params.id]
);
if (result.rows.length === 0) return res.status(404).json({ error: 'Source not found' });
res.json(result.rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// deregister a source — does not drop existing forecast tables
router.get('/dim-period/cols', async (req, res) => {
try {
const result = await pool.query(`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'pf' AND table_name = 'dim_period'
AND column_name NOT IN ('sdat', 'edat', 'drange', 'ndays')
ORDER BY ordinal_position
`);
res.json(result.rows.map(r => r.column_name));
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
router.delete('/sources/:id', async (req, res) => {
try {
const result = await pool.query(
`DELETE FROM pf.source WHERE id = $1 RETURNING *`,
[req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Source not found' });
}
res.json({ message: 'Source deregistered', source: result.rows[0] });
} catch (err) {
console.error(err);
if (err.code === '23001' || err.code === '23503') {
return res.status(409).json({ error: 'Source has existing versions — delete them first.' });
}
res.status(500).json({ error: err.message });
}
});
return router;
};