Make units column optional throughout source registration and SQL generation

- SQL generator no longer requires a units col; recode/clone/scale omit units
  expressions when none is configured in col_meta
- Source registration validation drops units from required roles (value + date
  are the only hard requirements)
- DELETE /api/sources/:id returns 409 when existing versions reference the source
- Setup.jsx surfaces the 409 error via flash instead of silently failing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-06-12 23:44:06 -04:00
parent 101cb27604
commit 54c93c28dd
3 changed files with 13 additions and 7 deletions

View File

@ -21,7 +21,6 @@ function generateSQL(source, colMeta) {
const dateCol = colMeta.find(c => c.role === 'date')?.cname; const dateCol = colMeta.find(c => c.role === 'date')?.cname;
if (!valueCol) throw new Error('No value column defined in col_meta'); 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 (!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'); if (dims.length === 0) throw new Error('No dimension columns defined in col_meta');
@ -171,14 +170,14 @@ ilog AS (
) )
,neg AS ( ,neg AS (
INSERT INTO {{fc_table}} (${insertCols}) INSERT INTO {{fc_table}} (${insertCols})
SELECT ${dimsJoined}, ${q(dateCol)}, ${effectiveValue ? `-${q(effectiveValue)}` : '0'}, ${effectiveUnits ? `-${q(effectiveUnits)}` : '0'}, SELECT ${dimsJoined}, ${q(dateCol)}, ${effectiveValue ? `-${q(effectiveValue)}` : '0'}${effectiveUnits ? `, -${q(effectiveUnits)}` : ''},
'recode', (SELECT id FROM ilog), '{{pf_user}}', now() 'recode', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM src FROM src
RETURNING * RETURNING *
) )
,ins AS ( ,ins AS (
INSERT INTO {{fc_table}} (${insertCols}) INSERT INTO {{fc_table}} (${insertCols})
SELECT {{set_clause}}, ${q(dateCol)}, ${effectiveValue ? q(effectiveValue) : '0'}, ${effectiveUnits ? q(effectiveUnits) : '0'}, SELECT {{set_clause}}, ${q(dateCol)}, ${effectiveValue ? q(effectiveValue) : '0'}${effectiveUnits ? `, ${q(effectiveUnits)}` : ''},
'recode', (SELECT id FROM ilog), '{{pf_user}}', now() 'recode', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM src FROM src
RETURNING * RETURNING *
@ -199,8 +198,7 @@ ilog AS (
SELECT SELECT
{{set_clause}}, {{set_clause}},
${q(dateCol)}, ${q(dateCol)},
${effectiveValue ? `round(${q(effectiveValue)} * {{scale_factor}}, 2)` : '0'}, ${effectiveValue ? `round(${q(effectiveValue)} * {{scale_factor}}, 2)` : '0'}${effectiveUnits ? `,\n round(${q(effectiveUnits)} * {{scale_factor}}, 5)` : ''},
${effectiveUnits ? `round(${q(effectiveUnits)} * {{scale_factor}}, 5)` : '0'},
'clone', (SELECT id FROM ilog), '{{pf_user}}', now() 'clone', (SELECT id FROM ilog), '{{pf_user}}', now()
FROM {{fc_table}} FROM {{fc_table}}
WHERE {{where_clause}} WHERE {{where_clause}}

View File

@ -143,7 +143,7 @@ module.exports = function(pool) {
// validate required roles // validate required roles
const colMeta = colResult.rows; const colMeta = colResult.rows;
const roles = new Set(colMeta.map(c => c.role)); const roles = new Set(colMeta.map(c => c.role));
const missing = ['value', 'units', 'date'].filter(r => !roles.has(r)); const missing = ['value', 'date'].filter(r => !roles.has(r));
if (missing.length > 0) { if (missing.length > 0) {
return res.status(400).json({ return res.status(400).json({
error: `col_meta is missing required roles: ${missing.join(', ')}` error: `col_meta is missing required roles: ${missing.join(', ')}`
@ -292,6 +292,9 @@ module.exports = function(pool) {
res.json({ message: 'Source deregistered', source: result.rows[0] }); res.json({ message: 'Source deregistered', source: result.rows[0] });
} catch (err) { } catch (err) {
console.error(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 }); res.status(500).json({ error: err.message });
} }
}); });

View File

@ -140,7 +140,12 @@ export default function Setup({ refreshSources }) {
async function deleteSource(id, e) { async function deleteSource(id, e) {
e.stopPropagation() e.stopPropagation()
if (!confirm('Deregister this source? Existing forecast tables are not affected.')) return if (!confirm('Deregister this source? Existing forecast tables are not affected.')) return
await fetch(`/api/sources/${id}`, { method: 'DELETE' }) const res = await fetch(`/api/sources/${id}`, { method: 'DELETE' })
if (!res.ok) {
const data = await res.json().catch(() => ({}))
flash(data.error || 'Delete failed', 'err')
return
}
if (selectedSource?.id === id) { setSelectedSource(null); setCols([]); setEditedCols([]) } if (selectedSource?.id === id) { setSelectedSource(null); setCols([]); setEditedCols([]) }
loadSources() loadSources()
} }