I ended up creating three functions. Probably could have done less, but the functions would be reusable in other queries. Basically, any place in the JSON output that should have an array of values, that gets handled by a function that returns a recordset that gets json_agg()'ed.
CREATE OR REPLACE FUNCTION get_measures_by_inventory_as_json(invid UUID, del TIMESTAMP WITH TIME ZONE DEFAULT now())
RETURNS TABLE(inventory_id UUID, measure_json JSON)
AS $$
-- returns a JSONified record per measure tied to an inventory record
SELECT m.inventory_id, json_build_object(
'id', m.id,
'quantity', m.quantity,
'read', TRUNC(EXTRACT(EPOCH FROM m.read_date)),
'created', TRUNC(EXTRACT(EPOCH FROM m.created)),
'updated', TRUNC(EXTRACT(EPOCH FROM m.updated)),
'deleted', TRUNC(EXTRACT(EPOCH FROM m.deleted))
)
FROM measure m
WHERE m.inventory_id = invid
AND (m.deleted >= del);
$$
LANGUAGE sql;
CREATE OR REPLACE FUNCTION get_inventories_by_location_as_json(locid UUID, del TIMESTAMP WITH TIME ZONE DEFAULT now())
RETURNS TABLE(location_id UUID, inventory_json JSON)
AS $$
-- returns a JSONified set of inventory items, with product info and measures, given a location
SELECT i.location_id, json_build_object(
'id', i.id,
'product_id', p.id,
'name', p.name,
'mass_quantity', p.mass_quantity,
'mass_unit', um.code,
'count_unit', uc.code,
'thumb', p.product_picture_uri,
'sort_order', i.sort_order,
'par_level', i.par_level,
'created', TRUNC(EXTRACT(EPOCH FROM i.created)),
'updated', TRUNC(EXTRACT(EPOCH FROM i.updated)),
'deleted', TRUNC(EXTRACT(EPOCH FROM i.deleted)),
'measures', COALESCE((SELECT json_agg(measure_json) FROM get_measures_by_inventory_as_json(i.id)), '[]')::json
)
FROM inventory i
INNER JOIN product p ON i.product_id = p.id
LEFT JOIN unit um ON p.mass_unit_id = um.id
LEFT JOIN unit uc ON p.count_unit_id = uc.id
WHERE i.location_id = locid
AND i.deleted >= del
AND p.deleted >= del;
$$
LANGUAGE sql;
CREATE OR REPLACE FUNCTION get_inventories_recursive_as_json(locid UUID[], del TIMESTAMP WITH TIME ZONE DEFAULT now())
RETURNS JSON
AS $$
-- returns JSONified location info and inventories in that location
-- and recurses into child locations, showing the same
SELECT json_agg(loc) FROM (
SELECT l.id, array_agg(c.id), json_build_object(
'id', l.id,
'name', l.name,
'type', t.code,
'locations', get_inventories_recursive_as_json(array_agg(c.id)),
'inventories', COALESCE((SELECT json_agg(inventory_json) FROM get_inventories_by_location_as_json(l.id)),'[]')::json
) AS loc
FROM location l
LEFT OUTER JOIN location c ON l.id = c.parent_id
INNER JOIN location_type t ON l.location_type_id = t.id
WHERE l.id = ANY(locid)
AND l.deleted >= del
GROUP BY l.id, l.name, t.code
) AS out;
$$
LANGUAGE sql;
Tried to do it via a CTE, which would have been so elegant, but was unable to figure out how to do it without running afoul the errors related to the inability to aggregate in the recurse.
WITH RECURSIVE locations AS (
WITH inventories AS (
WITH measures AS (
SELECT m.inventory_id, json_agg(json_build_object(
'id', m.id,
'quantity', m.quantity,
'read', TRUNC(EXTRACT(EPOCH FROM m.read_date)),
'created', TRUNC(EXTRACT(EPOCH FROM m.created)),
'updated', TRUNC(EXTRACT(EPOCH FROM m.updated)),
'deleted', TRUNC(EXTRACT(EPOCH FROM m.deleted))
)) as measures
FROM measure m
GROUP BY m.inventory_id
)
SELECT i.location_id, json_agg(json_build_object(
'id', i.id,
'product_id', p.id,
'name', p.name,
'mass_quantity', p.mass_quantity,
'mass_unit', um.code,
'count_unit', uc.code,
'thumb', p.product_picture_uri,
'sort_order', i.sort_order,
'par_level', i.par_level,
'created', TRUNC(EXTRACT(EPOCH FROM i.created)),
'updated', TRUNC(EXTRACT(EPOCH FROM i.updated)),
'deleted', TRUNC(EXTRACT(EPOCH FROM i.deleted)),
'measures', COALESCE(m.measures, '[]')
)) AS inventories
FROM inventory i
INNER JOIN product p ON i.product_id = p.id
LEFT JOIN unit um ON p.mass_unit_id = um.id
LEFT JOIN unit uc ON p.count_unit_id = uc.id
LEFT JOIN measures m ON i.id = m.inventory_id
GROUP BY i.location_id
)
SELECT null as id, null as name, null as type, null as inventories
FROM location l
INNER JOIN location_type t ON l.location_type_id = t.id
LEFT OUTER JOIN inventories i ON l.id = i.location_id
GROUP BY l.parent_id
/*UNION ALL
SELECT p.id, p.parent_id, p.name, t.code, COALESCE(i.inventories, '[]')::jsonb AS inventories, json_agg(row_to_json(c.*))
FROM location p
INNER JOIN location_type t ON p.location_type_id = t.id
LEFT OUTER JOIN inventories i ON p.id = i.location_id
INNER JOIN locations c ON p.id = c.parent_id
GROUP BY p.id, p.name, t.code, COALESCE(i.inventories, '[]')::jsonb*/
)
SELECT * FROM locations