packages feed

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
        ]