dataflow/api/routes/rules.js
Paul Trowbridge 928a54932d Add multi-capture regex, computed view fields, collapsible rules, and live preview
- Support multi-capture-group regex: mappings.input_value changed to JSONB,
  regexp_match() result stored as scalar or array JSONB in transformed column
- Computed expression fields in generated views: {fieldname} refs substituted
  with (transformed->>'fieldname')::numeric for arithmetic in view columns
- Fix generate_source_view to DROP VIEW before CREATE (avoids column drop error)
- Collapsible rule cards that open directly to inline edit form
- Debounced live regex preview (extract + replace) with popout modal for 50 rows
- Records page now shows dfv.<source> view output instead of raw records
- Unified field table in Sources: single table with In view, Seq, expression columns
- Fix "Rule already exists" error when editing by passing rule.id directly to submit
- Fix Sources page clearing on F5 by watching sourceObj?.name in useEffect dep

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:37:15 -04:00

218 lines
7.8 KiB
JavaScript

/**
* Rules Routes
* Manage transformation rules
*/
const express = require('express');
module.exports = (pool) => {
const router = express.Router();
// List all rules for a source
router.get('/source/:source_name', async (req, res, next) => {
try {
const result = await pool.query(
'SELECT * FROM rules WHERE source_name = $1 ORDER BY sequence, name',
[req.params.source_name]
);
res.json(result.rows);
} catch (err) {
next(err);
}
});
// Preview an ad-hoc pattern against real records (no saved rule needed)
router.get('/preview', async (req, res, next) => {
try {
const { source, field, pattern, flags, function_type = 'extract', replace_value = '', limit = 20 } = req.query;
if (!source || !field || !pattern) {
return res.status(400).json({ error: 'source, field, and pattern are required' });
}
const fullPattern = (flags ? `(?${flags})` : '') + pattern;
const query = function_type === 'replace'
? `SELECT
id,
data->>$1 AS raw_value,
to_jsonb(regexp_replace(data->>$1, $2, $3)) AS extracted_value
FROM records
WHERE source_name = $4 AND data ? $1
ORDER BY id DESC LIMIT $5`
: `SELECT
r.id,
r.data->>$1 AS raw_value,
CASE
WHEN m.match IS NULL THEN NULL
WHEN cardinality(m.match) = 1 THEN to_jsonb(m.match[1])
ELSE to_jsonb(m.match)
END AS extracted_value
FROM records r
CROSS JOIN LATERAL (SELECT regexp_match(r.data->>$1, $2) AS match) m
WHERE r.source_name = $3 AND r.data ? $1
ORDER BY r.id DESC LIMIT $4`;
const params = function_type === 'replace'
? [field, fullPattern, replace_value, source, parseInt(limit)]
: [field, fullPattern, source, parseInt(limit)];
const result = await pool.query(query, params);
res.json(result.rows);
} catch (err) {
next(err);
}
});
// Test a rule against real records
router.get('/:id/test', async (req, res, next) => {
try {
const { limit = 20 } = req.query;
const ruleResult = await pool.query(
'SELECT * FROM rules WHERE id = $1',
[req.params.id]
);
if (ruleResult.rows.length === 0) {
return res.status(404).json({ error: 'Rule not found' });
}
const rule = ruleResult.rows[0];
const pattern = (rule.flags ? `(?${rule.flags})` : '') + rule.pattern;
const result = await pool.query(
`SELECT
r.id,
r.data->>$1 AS raw_value,
CASE
WHEN m.match IS NULL THEN NULL
WHEN cardinality(m.match) = 1 THEN to_jsonb(m.match[1])
ELSE to_jsonb(m.match)
END AS extracted_value
FROM records r
CROSS JOIN LATERAL (SELECT regexp_match(r.data->>$1, $2) AS match) m
WHERE r.source_name = $3
AND r.data ? $1
ORDER BY r.id DESC
LIMIT $4`,
[rule.field, pattern, rule.source_name, parseInt(limit)]
);
res.json({
rule: { id: rule.id, name: rule.name, field: rule.field, pattern: rule.pattern, output_field: rule.output_field },
results: result.rows
});
} catch (err) {
next(err);
}
});
// Get single rule
router.get('/:id', async (req, res, next) => {
try {
const result = await pool.query(
'SELECT * FROM rules WHERE id = $1',
[req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Rule not found' });
}
res.json(result.rows[0]);
} catch (err) {
next(err);
}
});
// Create rule
router.post('/', async (req, res, next) => {
try {
const { source_name, name, field, pattern, output_field, function_type, flags, replace_value, enabled, sequence } = req.body;
if (!source_name || !name || !field || !pattern || !output_field) {
return res.status(400).json({
error: 'Missing required fields: source_name, name, field, pattern, output_field'
});
}
if (function_type && !['extract', 'replace'].includes(function_type)) {
return res.status(400).json({ error: 'function_type must be "extract" or "replace"' });
}
const result = await pool.query(
`INSERT INTO rules (source_name, name, field, pattern, output_field, function_type, flags, replace_value, enabled, sequence)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *`,
[source_name, name, field, pattern, output_field, function_type || 'extract', flags || '', replace_value || '', enabled !== false, sequence || 0]
);
res.status(201).json(result.rows[0]);
} catch (err) {
if (err.code === '23505') { // Unique violation
return res.status(409).json({ error: 'Rule already exists for this source' });
}
if (err.code === '23503') { // Foreign key violation
return res.status(404).json({ error: 'Source not found' });
}
next(err);
}
});
// Update rule
router.put('/:id', async (req, res, next) => {
try {
const { name, field, pattern, output_field, function_type, flags, replace_value, enabled, sequence } = req.body;
if (function_type && !['extract', 'replace'].includes(function_type)) {
return res.status(400).json({ error: 'function_type must be "extract" or "replace"' });
}
const result = await pool.query(
`UPDATE rules
SET name = COALESCE($2, name),
field = COALESCE($3, field),
pattern = COALESCE($4, pattern),
output_field = COALESCE($5, output_field),
function_type = COALESCE($6, function_type),
flags = COALESCE($7, flags),
replace_value = COALESCE($8, replace_value),
enabled = COALESCE($9, enabled),
sequence = COALESCE($10, sequence)
WHERE id = $1
RETURNING *`,
[req.params.id, name, field, pattern, output_field, function_type, flags, replace_value, enabled, sequence]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Rule not found' });
}
res.json(result.rows[0]);
} catch (err) {
next(err);
}
});
// Delete rule
router.delete('/:id', async (req, res, next) => {
try {
const result = await pool.query(
'DELETE FROM rules WHERE id = $1 RETURNING id, name',
[req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'Rule not found' });
}
res.json({ success: true, deleted: result.rows[0] });
} catch (err) {
next(err);
}
});
return router;
};