diff --git a/api/routes/stacks.js b/api/routes/stacks.js index 7d88271..3f0a1aa 100644 --- a/api/routes/stacks.js +++ b/api/routes/stacks.js @@ -98,6 +98,14 @@ module.exports = (pool) => { } catch (err) { next(err); } }); + // Preview the SQL that would be generated (dry run — does not create the view) + router.get('/:name/view-sql', async (req, res, next) => { + try { + const result = await pool.query(`SELECT generate_stack_view(${lit(req.params.name)}, true) AS result`); + res.json(result.rows[0].result); + } catch (err) { next(err); } + }); + // Generate / refresh the dfv view router.post('/:name/view', async (req, res, next) => { try { @@ -110,6 +118,33 @@ module.exports = (pool) => { } catch (err) { next(err); } }); + // Execute custom SQL for the view (user-edited SQL) + router.post('/:name/exec-sql', async (req, res, next) => { + try { + const { sql } = req.body; + if (!sql) return res.status(400).json({ success: false, error: 'sql is required' }); + await pool.query(`DROP VIEW IF EXISTS dfv.${req.params.name} CASCADE`); + await pool.query(sql); + await pool.query(`UPDATE dataflow.stacks SET view_generated_at = NOW() WHERE name = ${lit(req.params.name)}`); + // Detect stacks whose views were dropped by CASCADE + const staleResult = await pool.query(` + SELECT array_agg(name) AS names FROM dataflow.stacks + WHERE name != ${lit(req.params.name)} + AND view_generated_at IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM pg_views WHERE schemaname = 'dfv' AND viewname = name + ) + `); + const cascadeStale = staleResult.rows[0].names || []; + if (cascadeStale.length) { + await pool.query(`UPDATE dataflow.stacks SET view_generated_at = NULL WHERE name = ANY($1)`, [cascadeStale]); + } + res.json({ success: true, cascade_stale: cascadeStale }); + } catch (err) { + res.json({ success: false, error: err.message }); + } + }); + // Calibrate balance offset given a known good balance at a specific date router.post('/:name/calibrate', async (req, res, next) => { try { diff --git a/database/queries/stacks.sql b/database/queries/stacks.sql index 6139ad6..b7519a2 100644 --- a/database/queries/stacks.sql +++ b/database/queries/stacks.sql @@ -46,6 +46,7 @@ ALTER TABLE dataflow.stack_sources ADD COLUMN IF NOT EXISTS date_field TEXT; -- Drop old signatures before recreating DROP FUNCTION IF EXISTS calibrate_balance(TEXT, DATE, NUMERIC); DROP FUNCTION IF EXISTS upsert_stack_source(TEXT, TEXT, JSONB, INTEGER, NUMERIC); +DROP FUNCTION IF EXISTS generate_stack_view(TEXT); ------------------------------------------------------ -- Function: list_stacks @@ -239,7 +240,7 @@ $$ LANGUAGE plpgsql STABLE; -- Each source CTE applies amount_sign and computes a per-source running balance. -- Outer SELECT adds net_balance across all sources. ------------------------------------------------------ -CREATE OR REPLACE FUNCTION generate_stack_view(p_stack_name TEXT) +CREATE OR REPLACE FUNCTION generate_stack_view(p_stack_name TEXT, p_dry_run BOOLEAN DEFAULT false) RETURNS JSON AS $$ DECLARE v_stack dataflow.stacks%ROWTYPE; @@ -258,6 +259,7 @@ DECLARE v_canon_cols TEXT; v_src_bal_cols TEXT; v_total_offset NUMERIC := 0; + v_cascade_stale TEXT[]; BEGIN SELECT * INTO v_stack FROM dataflow.stacks WHERE name = p_stack_name; IF NOT FOUND THEN @@ -327,9 +329,7 @@ BEGIN RETURN json_build_object('success', false, 'error', 'Stack has no sources'); END IF; - CREATE SCHEMA IF NOT EXISTS dfv; v_view := 'dfv.' || quote_ident(p_stack_name); - EXECUTE format('DROP VIEW IF EXISTS %s CASCADE', v_view); v_canon_cols := ( SELECT string_agg(quote_ident(f->>'name'), ', ') @@ -337,23 +337,21 @@ BEGIN ); IF v_has_bal THEN - -- net_balance = sum of all signed amounts + total of all source offsets - -- This ensures net_balance = sum of per-source carried-forward balances on every row v_sql := format( 'CREATE VIEW %s AS ' 'WITH %s, _stacked AS (SELECT * FROM %s) ' 'SELECT _source, _id, %s, ' - 'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance, ' - '%s ' + '%s, ' + 'SUM(%I) OVER (ORDER BY %I ASC, _id ASC) + %s AS net_balance ' 'FROM _stacked ORDER BY %I DESC, _id DESC', v_view, array_to_string(v_ctes, ', '), array_to_string(v_cte_names, ' UNION ALL SELECT * FROM '), v_canon_cols, + v_src_bal_cols, v_stack.amount_field, v_stack.date_field, v_total_offset, - v_src_bal_cols, v_stack.date_field ); ELSE @@ -368,9 +366,31 @@ BEGIN ); END IF; - EXECUTE v_sql; + IF NOT p_dry_run THEN + CREATE SCHEMA IF NOT EXISTS dfv; + EXECUTE format('DROP VIEW IF EXISTS %s CASCADE', v_view); + EXECUTE v_sql; - RETURN json_build_object('success', true, 'view', v_view, 'sql', v_sql); + -- Detect stacks whose views were dropped by CASCADE and mark them stale + SELECT array_agg(s.name) INTO v_cascade_stale + FROM dataflow.stacks s + WHERE s.name != p_stack_name + AND s.view_generated_at IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM pg_views v + WHERE v.schemaname = 'dfv' AND v.viewname = s.name + ); + + UPDATE dataflow.stacks SET view_generated_at = NULL + WHERE name = ANY(v_cascade_stale); + END IF; + + RETURN json_build_object( + 'success', true, + 'view', v_view, + 'sql', v_sql, + 'cascade_stale', COALESCE(to_json(v_cascade_stale), '[]'::json) + ); END; $$ LANGUAGE plpgsql; @@ -410,6 +430,6 @@ BEGIN END; $$ LANGUAGE plpgsql STABLE; -COMMENT ON FUNCTION generate_stack_view IS 'Generate a UNION ALL view in dfv schema combining multiple sources with optional running balance'; +COMMENT ON FUNCTION generate_stack_view(TEXT, BOOLEAN) IS 'Generate a UNION ALL view in dfv schema combining multiple sources with optional running balance; p_dry_run=true returns SQL without executing'; COMMENT ON FUNCTION calibrate_balance IS 'Given a known good balance at a date, compute the offset to add to balance_offset'; COMMENT ON FUNCTION get_stack_balance IS 'Return the current running balance (last row) from the generated dfv view'; diff --git a/ui/package.json b/ui/package.json index 9441819..a6b2906 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,7 +12,8 @@ "dependencies": { "react": "^19.2.4", "react-dom": "^19.2.4", - "react-router-dom": "^7.13.2" + "react-router-dom": "^7.13.2", + "sql-formatter": "^15.7.3" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/ui/src/api.js b/ui/src/api.js index 65e93b3..d37313b 100644 --- a/ui/src/api.js +++ b/ui/src/api.js @@ -121,7 +121,9 @@ export const api = { deleteStack: (name) => request('DELETE', `/stacks/${name}`), upsertStackSource: (name, source, body) => request('PUT', `/stacks/${name}/sources/${source}`, body), removeStackSource: (name, source) => request('DELETE', `/stacks/${name}/sources/${source}`), + previewStackSql: (name) => request('GET', `/stacks/${name}/view-sql`), generateStackView: (name) => request('POST', `/stacks/${name}/view`), + execStackSql: (name, sql) => request('POST', `/stacks/${name}/exec-sql`, { sql }), getStackBalance: (name) => request('GET', `/stacks/${name}/balance`), calibrateBalance: (name, source, body) => request('POST', `/stacks/${name}/calibrate`, { ...body, source_name: source || null }), diff --git a/ui/src/pages/Stacks.jsx b/ui/src/pages/Stacks.jsx index e213fa8..fd6da27 100644 --- a/ui/src/pages/Stacks.jsx +++ b/ui/src/pages/Stacks.jsx @@ -1,5 +1,14 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { api } from '../api' +import { format as formatSql } from 'sql-formatter' + +function prettySql(sql) { + try { + return formatSql(sql, { language: 'postgresql', tabWidth: 4, keywordCase: 'upper' }) + } catch { + return sql + } +} const FIELD_TYPES = ['text', 'numeric', 'date'] @@ -79,7 +88,7 @@ function CalibrateModal({ stack, sourceName, onClose, onApply }) { // ── Stack panel ──────────────────────────────────────────────────────────────── -function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) { +function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated, onSqlGenerated }) { const members = stack.sources || [] const [label, setLabel] = useState(stack.label || '') @@ -126,6 +135,22 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) { }) }, [members.map(m => m.source_name).join(',')]) + // Live SQL preview — debounced whenever fields, sources, or mappings change + const previewTimer = useRef(null) + useEffect(() => { + clearTimeout(previewTimer.current) + previewTimer.current = setTimeout(() => { + api.previewStackSql(stack.name) + .then(r => { if (r.success) onSqlGenerated?.(r.sql) }) + .catch(() => {}) + }, 400) + return () => clearTimeout(previewTimer.current) + }, [ + JSON.stringify(fields), + JSON.stringify(srcCfg), + members.map(m => m.source_name).join(','), + ]) + // Auto-detect canonical amount/date field from field types const amountCanonical = fields.find(f => f.type === 'numeric')?.name || stack.amount_field const dateCanonical = fields.find(f => f.type === 'date')?.name || stack.date_field @@ -316,7 +341,12 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) { try { const r = await api.generateStackView(stack.name) setViewResult(r) - if (r.success) { fetchBalance(); onViewGenerated?.(stack.name) } + if (r.success) { + fetchBalance() + onViewGenerated?.(stack.name) + onSqlGenerated?.(r.sql || '') + ;(r.cascade_stale || []).forEach(n => onStale?.(n)) + } } catch (e) { setError(e.message) } } @@ -547,16 +577,11 @@ function StackPanel({ stack, sources, onUpdated, onStale, onViewGenerated }) { )} {balanceError &&
{balanceError}
} - {viewResult && ( -View created: {viewResult.view}
- :{viewResult.error}
- } - {viewResult.sql && ( -{viewResult.sql}
- )}
- {viewResult.error}
+ )} + {viewResult && viewResult.success && ( +View created: {viewResult.view}
)} @@ -581,6 +606,9 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated }) const [creating, setCreating] = useState(false) const [newName, setNewName] = useState('') const [error, setError] = useState('') + const [sqlDraft, setSqlDraft] = useState('') + const [sqlRunning, setSqlRunning] = useState(false) + const [sqlResult, setSqlResult] = useState(null) async function load() { const s = await api.getStacks() @@ -591,6 +619,8 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated }) const s = await api.getStack(name) setStackDetail(s) setSelected(name) + setSqlDraft('') + setSqlResult(null) } useEffect(() => { load() }, []) @@ -610,10 +640,24 @@ export default function Stacks({ sources, onStackStale, onStackViewGenerated }) async function deleteStack(name) { if (!confirm(`Delete stack "${name}"?`)) return await api.deleteStack(name) - if (selected === name) { setSelected(null); setStackDetail(null) } + if (selected === name) { setSelected(null); setStackDetail(null); setSqlDraft(''); setSqlResult(null) } load() } + async function runSql() { + if (!sqlDraft.trim() || !selected) return + setSqlRunning(true); setSqlResult(null) + try { + const r = await api.execStackSql(selected, sqlDraft) + setSqlResult(r) + if (r.success) { + onStackViewGenerated?.(selected) + ;(r.cascade_stale || []).forEach(n => onStackStale?.(n)) + } + } catch (e) { setSqlResult({ success: false, error: e.message }) } + finally { setSqlRunning(false) } + } + return (Select a stack or create one.
)}