database/functions.sql held a full pg_dump appended onto the original hand-written file, duplicating ~50 functions that had since been split into database/queries/. Nothing deployed it, but CLAUDE.md and the tutorial still told you to psql it, which would have reverted the split versions. The reverse had also happened: five functions in queries/ were behind the live database, all of them undoing the May 2026 split of the transformed column. preview_rule lost its data -> transformed fallback for chained rules; set_/clear_/bulk_set_record_overrides wrote overrides back into transformed and returned the wrong type (which would have made the redeploy error outright); generate_source_view read only transformed instead of merging all three layers. Those are corrected here from the live definitions. generate_source_view additionally regains the _overridden column that queries/ had and live lacked — Records.jsx reads row._overridden to highlight manually edited rows, so that indicator had been dead. The seven functions that existed only in functions.sql move to two new files, import.sql (import + audit trail) and transform.sql (the rule/mapping engine), leaving database/ flat: schema.sql plus one file per route. The four already applied migrate_*.sql scripts are removed. manage.py picks up the new files in QUERY_FILES, and its DB_ACTIONS set now keys off the action functions rather than duplicated label strings that no longer matched any menu entry, so the "into database X" hint renders again. uninstall.sh is folded into manage.py as menu option 10. Beyond what the script did, it stops/disables/removes the systemd unit, removes the nginx site with an nginx -t check before reloading, and deletes public/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
5.8 KiB
PL/PgSQL
157 lines
5.8 KiB
PL/PgSQL
--
|
|
-- Transform queries
|
|
-- The rule/mapping engine; SQL for the transform endpoints in api/routes/sources.js,
|
|
-- api/routes/rules.js and api/routes/records.js
|
|
--
|
|
-- Order matters within this file: the aggregate is used by apply_transformations,
|
|
-- which in turn is called by reprocess_records.
|
|
--
|
|
|
|
SET search_path TO dataflow, public;
|
|
|
|
-- ── Merge aggregate ───────────────────────────────────────────────────────────
|
|
|
|
-- Merge JSONB objects across rows (later rows win on key conflicts)
|
|
-- Usage: jsonb_concat_obj(col ORDER BY sequence)
|
|
CREATE OR REPLACE FUNCTION dataflow.jsonb_merge(a JSONB, b JSONB)
|
|
RETURNS JSONB AS $$
|
|
SELECT COALESCE(a, '{}') || COALESCE(b, '{}')
|
|
$$ LANGUAGE sql IMMUTABLE;
|
|
|
|
DROP AGGREGATE IF EXISTS dataflow.jsonb_concat_obj(JSONB);
|
|
CREATE AGGREGATE dataflow.jsonb_concat_obj(JSONB) (
|
|
sfunc = dataflow.jsonb_merge,
|
|
stype = JSONB,
|
|
initcond = '{}'
|
|
);
|
|
|
|
-- ── Apply rules and mappings ──────────────────────────────────────────────────
|
|
|
|
-- Writes only the rule/mapping output into records.transformed. Raw values stay in
|
|
-- data and manual edits stay in overrides; readers merge the three layers.
|
|
DROP FUNCTION IF EXISTS apply_transformations(TEXT, INTEGER[]);
|
|
CREATE OR REPLACE FUNCTION apply_transformations(
|
|
p_source_name TEXT,
|
|
p_record_ids INTEGER[] DEFAULT NULL, -- NULL = all eligible records
|
|
p_overwrite BOOLEAN DEFAULT FALSE -- FALSE = skip already-transformed, TRUE = overwrite all
|
|
) RETURNS JSON AS $$
|
|
WITH
|
|
-- All records to process
|
|
qualifying AS (
|
|
SELECT id, data
|
|
FROM dataflow.records
|
|
WHERE source_name = p_source_name
|
|
AND (p_overwrite OR transformed IS NULL)
|
|
AND (p_record_ids IS NULL OR id = ANY(p_record_ids))
|
|
),
|
|
-- Mirror TPS rx: fan out one row per regex match, drive from rules → records
|
|
rx AS (
|
|
SELECT
|
|
q.id,
|
|
r.name AS rule_name,
|
|
r.sequence,
|
|
r.output_field,
|
|
r.retain,
|
|
r.function_type,
|
|
COALESCE(mt.rn, rp.rn, 1) AS result_number,
|
|
-- extract: build map_val and retain_val per match (mirrors TPS)
|
|
CASE WHEN array_length(mt.mt, 1) = 1 THEN to_jsonb(mt.mt[1]) ELSE to_jsonb(mt.mt) END AS match_val,
|
|
to_jsonb(rp.rp) AS replace_val
|
|
FROM dataflow.rules r
|
|
INNER JOIN qualifying q ON q.data ? r.field
|
|
LEFT JOIN LATERAL regexp_matches(q.data ->> r.field, r.pattern, r.flags)
|
|
WITH ORDINALITY AS mt(mt, rn) ON r.function_type = 'extract'
|
|
LEFT JOIN LATERAL regexp_replace(q.data ->> r.field, r.pattern, r.replace_value, r.flags)
|
|
WITH ORDINALITY AS rp(rp, rn) ON r.function_type = 'replace'
|
|
WHERE r.source_name = p_source_name
|
|
AND r.enabled = true
|
|
),
|
|
-- Aggregate match rows back into one value per (record, rule) — mirrors TPS agg_to_target_items
|
|
agg_matches AS (
|
|
SELECT
|
|
id,
|
|
rule_name,
|
|
sequence,
|
|
output_field,
|
|
retain,
|
|
function_type,
|
|
CASE function_type
|
|
WHEN 'replace' THEN jsonb_agg(replace_val) -> 0
|
|
ELSE
|
|
CASE WHEN max(result_number) = 1
|
|
THEN jsonb_agg(match_val ORDER BY result_number) -> 0
|
|
ELSE jsonb_agg(match_val ORDER BY result_number)
|
|
END
|
|
END AS extracted
|
|
FROM rx
|
|
GROUP BY id, rule_name, sequence, output_field, retain, function_type
|
|
),
|
|
-- Join with mappings to find mapped output — mirrors TPS link_map
|
|
linked AS (
|
|
SELECT
|
|
a.id,
|
|
a.sequence,
|
|
a.output_field,
|
|
a.retain,
|
|
a.extracted,
|
|
m.output AS mapped
|
|
FROM agg_matches a
|
|
LEFT JOIN dataflow.mappings m ON
|
|
m.source_name = p_source_name
|
|
AND m.rule_name = a.rule_name
|
|
AND m.input_value = a.extracted
|
|
WHERE a.extracted IS NOT NULL
|
|
),
|
|
-- Build per-rule output JSONB:
|
|
-- mapped → use mapping output; also write output_field if retain = true
|
|
-- no map → write extracted value to output_field
|
|
rule_output AS (
|
|
SELECT
|
|
id,
|
|
sequence,
|
|
CASE
|
|
WHEN mapped IS NOT NULL THEN
|
|
mapped ||
|
|
CASE WHEN retain
|
|
THEN jsonb_build_object(output_field, extracted)
|
|
ELSE '{}'::jsonb
|
|
END
|
|
ELSE
|
|
jsonb_build_object(output_field, extracted)
|
|
END AS output
|
|
FROM linked
|
|
),
|
|
-- Merge all rule outputs per record in sequence order — mirrors TPS agg_to_id
|
|
record_additions AS (
|
|
SELECT
|
|
id,
|
|
dataflow.jsonb_concat_obj(output ORDER BY sequence) AS additions
|
|
FROM rule_output
|
|
GROUP BY id
|
|
),
|
|
-- Update all qualifying records; records with no rule matches get an empty object
|
|
updated AS (
|
|
UPDATE dataflow.records rec
|
|
SET transformed = COALESCE(ra.additions, '{}'::jsonb),
|
|
transformed_at = CURRENT_TIMESTAMP
|
|
FROM qualifying q
|
|
LEFT JOIN record_additions ra ON ra.id = q.id
|
|
WHERE rec.id = q.id
|
|
RETURNING rec.id
|
|
)
|
|
SELECT json_build_object('success', true, 'transformed', count(*))
|
|
FROM updated
|
|
$$ LANGUAGE sql;
|
|
|
|
COMMENT ON FUNCTION apply_transformations IS 'Apply transformation rules and mappings to records (set-based CTE)';
|
|
|
|
-- ── Reprocess ─────────────────────────────────────────────────────────────────
|
|
|
|
CREATE OR REPLACE FUNCTION reprocess_records(p_source_name TEXT)
|
|
RETURNS JSON AS $$
|
|
-- Overwrite all records directly — no clear step, mirrors TPS srce_map_overwrite
|
|
SELECT dataflow.apply_transformations(p_source_name, NULL, TRUE)
|
|
$$ LANGUAGE sql;
|
|
|
|
COMMENT ON FUNCTION reprocess_records IS 'Reapply all transformations for a source, overwriting existing values';
|