bolty-0.1.0.0: src/Database/Bolty/ResultSet.hs
-- | Multi-pass decoding of denormalized query results.
--
-- When a Cypher query uses @OPTIONAL MATCH@, the result set is denormalized:
-- parent fields are repeated for each matched child row. 'ResultSet' bundles
-- field names with records so you can decode the same rows with different
-- decoders in multiple passes.
--
-- __Preferred alternative:__ Use Cypher @COLLECT()@ or pattern comprehensions
-- to aggregate children server-side, avoiding denormalization entirely:
--
-- @
-- MATCH (p:Parent)
-- OPTIONAL MATCH (p)-[:HAS]->(c:Child)
-- RETURN p, COLLECT(c) AS children
-- @
--
-- When that isn't practical (e.g. multiple independent optional paths),
-- use 'groupByField' as an escape hatch:
--
-- @
-- rs <- queryResult conn "MATCH (p:Parent) OPTIONAL MATCH (p)-[:HAS]->(c:Child) RETURN p.id AS pid, c"
-- groups <- either throwIO pure $ groupByField (field \"pid\" (nullable int64)) rs
-- -- groups :: Vector (Int64, ResultSet)
-- -- Each sub-ResultSet contains the rows for that parent.
-- @
module Database.Bolty.ResultSet
( ResultSet(..)
, decodeResultSet
, decodeHead
, groupByField
) where
import Data.Kind (Type)
import qualified Data.Text as T
import qualified Data.Vector as V
import Database.Bolty.Decode (DecodeError(..), RowDecoder, decodeRow, decodeRows)
import Database.Bolty.Record (Record)
-- | A query result: field (column) names paired with the result records.
-- Supports multi-pass decoding — decode the same rows with different
-- 'RowDecoder's without re-running the query.
type ResultSet :: Type
data ResultSet = ResultSet
{ fields :: !(V.Vector T.Text)
, records :: !(V.Vector Record)
} deriving stock (Show, Eq)
-- | Decode every record in a 'ResultSet' using a 'RowDecoder'.
-- Fails on the first 'DecodeError'.
decodeResultSet :: RowDecoder a -> ResultSet -> Either DecodeError (V.Vector a)
decodeResultSet decoder (ResultSet fs rs) = decodeRows decoder fs rs
-- | Decode the first record of a 'ResultSet', or fail with 'EmptyResultSet'.
decodeHead :: RowDecoder a -> ResultSet -> Either DecodeError a
decodeHead decoder (ResultSet fs rs) = case V.uncons rs of
Nothing -> Left EmptyResultSet
Just (rec, _) -> decodeRow decoder fs rec
-- | Group consecutive records by a decoded key field.
--
-- Records whose key decodes to 'Nothing' (e.g. NULL from an @OPTIONAL MATCH@
-- with no match) are skipped. Grouping is consecutive, not global: if the same
-- key appears in non-adjacent runs, they become separate groups.
--
-- Each sub-'ResultSet' shares the parent's field names.
--
-- __Prefer Cypher @COLLECT()@__ when possible — this function is an escape
-- hatch for queries where server-side aggregation is impractical.
groupByField
:: Eq k
=> RowDecoder (Maybe k)
-> ResultSet
-> Either DecodeError (V.Vector (k, ResultSet))
groupByField keyDecoder (ResultSet fs rs) =
buildGroups =<< V.foldM' accumulate [] rs
where
accumulate acc rec = do
mk <- decodeRow keyDecoder fs rec
pure $ case mk of
Nothing -> acc -- skip NULL keys
Just k -> case acc of
(curK, curRecs) : rest
| curK == k -> (curK, rec : curRecs) : rest
_ -> (k, [rec]) : acc
buildGroups groups =
Right $ V.fromList
[ (k, ResultSet fs (V.fromList (reverse recs)))
| (k, recs) <- reverse groups
]