graphql-engine/server/src-lib/Hasura/Backends/MySQL/DataLoader/Execute.hs
Chris Done 4bbdb7a67d Add Plan part for the MySQL data loader
This is the next part in the series of MySQL PRs.

**Purpose**: Adds the Plan module for the data loader.

**Preceding PR:** #2511

**Next PR:** #2549

After #2511 is merged, this can be repointed to `main`. For now it's aimed at #2511, because then the diff display is simpler.

The `undefined` stubs in this PR have code already written, so they won't introduce a maintenance problem. They're only omitted for digestibility of the PR.

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/2529
Co-authored-by: Abby Sassel <3883855+sassela@users.noreply.github.com>
GitOrigin-RevId: 691b35be247531d5e1ac855598e89f6dc1eca0b6
2021-10-14 17:07:56 +00:00

379 lines
12 KiB
Haskell

{-# LANGUAGE UndecidableInstances #-}
-- |
--
-- Execute the plan given from .Plan.
module Hasura.Backends.MySQL.DataLoader.Execute where
import Control.Monad.IO.Class
import Data.Aeson hiding (Value)
import Data.Aeson qualified as J
import Data.Bifunctor
import Data.Foldable
import Data.Graph
import Data.HashMap.Strict.InsOrd qualified as OMap
import Data.IORef
import Data.Vector (Vector)
import Data.Vector qualified as V
import GHC.TypeLits qualified
import Hasura.Backends.MySQL.DataLoader.Plan hiding
( Join (wantedFields),
Relationship (leftRecordSet),
Select,
)
import Hasura.Backends.MySQL.DataLoader.Plan qualified as DataLoaderPlan
import Hasura.Backends.MySQL.DataLoader.Plan qualified as Plan
import Hasura.Backends.MySQL.Types hiding
( FieldName,
ScalarValue,
selectWhere,
)
import Hasura.Backends.MySQL.Types qualified as MySQL
import Hasura.GraphQL.Parser ()
-- Brings an instance for Hashable (Vector a)...
import Hasura.Prelude hiding
( concatMap,
elem,
head,
map,
mapMaybe,
tail,
toList,
)
-- | A set of records produced by the database. These are joined
-- together. There are all sorts of optimizations possible here, from
-- using a matrix/flat vector, unboxed sums for Value, etc. Presently
-- we choose a naive implementation in the interest of getting other
-- work done.
data RecordSet = RecordSet
{ origin :: !(Maybe PlannedAction),
rows :: !(Vector (InsOrdHashMap FieldName OutputValue)),
wantedFields :: !(Maybe [Text])
}
deriving (Show)
instance GHC.TypeLits.TypeError ('GHC.TypeLits.Text "Aeson loses key order, so you can't use this instance.") => ToJSON RecordSet where
toJSON RecordSet {} = error "RecordSet.toJSON: do not use."
-- | The read-only info. used by the Execute monad. Later, this IORef
-- may become either atomically modified or in an STM or MVar so that
-- jobs can be executed in parallel.
data ExecuteReader = ExecuteReader
{ recordSets :: IORef (InsOrdHashMap Ref RecordSet),
credentials :: !SourceConfig
}
-- | Any problem encountered while executing the plan.
data ExecuteProblem
= JoinProblem ExecuteProblem
| UnsupportedJoinBug JoinType
| MissingRecordSetBug Ref
deriving (Show)
-- | Execute monad; as queries are performed, the record sets are
-- stored in the map.
newtype Execute a = Execute
{unExecute :: ReaderT ExecuteReader (ExceptT ExecuteProblem IO) a}
deriving
( Functor,
Applicative,
Monad,
MonadReader ExecuteReader,
MonadIO,
MonadError ExecuteProblem
)
-- | A value outputted by this execute module in a record set.
data OutputValue
= ArrayOutputValue !(Vector OutputValue)
| RecordOutputValue !(InsOrdHashMap DataLoaderPlan.FieldName OutputValue)
| ScalarOutputValue !J.Value -- TODO: switch to 'MySQL.Scalar...'?
| NullOutputValue
deriving (Show, Eq, Generic)
instance Hashable OutputValue
--------------------------------------------------------------------------------
-- Main entry points
-- | Using the config, run the execute action. Finally, resolve the
-- head-and-tail to a record set.
runExecute ::
MonadIO m =>
SourceConfig ->
HeadAndTail ->
Execute a ->
m (Either ExecuteProblem RecordSet)
runExecute credentials headAndTail action = do
recordSets <- liftIO (newIORef mempty)
liftIO $
runExceptT $
runReaderT
(unExecute (action >> getFinalRecordSet headAndTail))
(ExecuteReader {credentials, recordSets})
-- | Execute the forest of actions.
execute :: Forest PlannedAction -> Execute ()
execute = traverse_ (traverse_ executePlannedAction)
-- | Execute an action, then store its result in the ref assigned to it.
executePlannedAction :: PlannedAction -> Execute ()
executePlannedAction PlannedAction {ref, action} =
fetchRecordSetForAction action >>= saveRecordSet ref
-- | Fetch the record set for the given action.
fetchRecordSetForAction :: Action -> Execute RecordSet
fetchRecordSetForAction =
\case
SelectAction select -> do
_relationshipIn <-
maybe (pure []) makeRelationshipIn (selectRelationship select)
-- TODO: This record set is set to empty for now. In a follow-up
-- code change, this will be pulled from the Connection
-- module. However, it requires changes to the Select type,
-- which in turn affect FromIr. In the interest of a simple PR,
-- this is omitted. This comment will be removed when the below
-- is updated in the follow-up PR.
recordSet <- pure (makeRecordSet mempty)
-- Update the wanted fields from the original select. This lets
-- the executor know which fields to include after performing a
-- join.
pure recordSet {wantedFields = Plan.selectWantedFields select}
JoinAction Plan.Join {joinType = joinType', joinFieldName = fieldName, ..} -> do
left <- getRecordSet leftRecordSet
right <- getRecordSet rightRecordSet
case joinType' of
ArrayJoin fields ->
case leftArrayJoin
wantedFields
fieldName
(toFieldNames fields)
joinRhsTop
joinRhsOffset
left
right of
Left problem -> throwError (JoinProblem problem)
Right recordSet -> pure recordSet
ObjectJoin fields ->
case leftObjectJoin
wantedFields
fieldName
(toFieldNames fields)
left
right of
Left problem -> throwError (JoinProblem problem)
Right recordSet -> pure recordSet
_ -> throwError (UnsupportedJoinBug joinType')
where
toFieldNames = fmap (bimap toFieldName toFieldName)
-- | Make a record set from a flat record from the DB.
makeRecordSet :: Vector (InsOrdHashMap FieldName J.Value) -> RecordSet
makeRecordSet rows =
RecordSet
{ origin = Nothing, -- No information for this yet, but will follow
-- up with a change for this later.
rows = fmap (fmap ScalarOutputValue) rows,
wantedFields = Nothing
}
saveRecordSet :: Ref -> RecordSet -> Execute ()
saveRecordSet ref recordSet = do
recordSetsRef <- asks recordSets
liftIO (modifyIORef' recordSetsRef (OMap.insert ref recordSet))
getRecordSet :: Ref -> Execute RecordSet
getRecordSet ref = do
recordSetsRef <- asks recordSets
hash <- liftIO (readIORef recordSetsRef)
case OMap.lookup ref hash of
Nothing -> throwError (MissingRecordSetBug ref)
Just re -> pure re
-- | See documentation for 'HeadAndTail'.
getFinalRecordSet :: HeadAndTail -> Execute RecordSet
getFinalRecordSet HeadAndTail {..} = do
headSet <- getRecordSet head
tailSet <-
if tail /= head
then getRecordSet tail
else pure headSet
pure
tailSet
{ rows =
fmap
( \row ->
OMap.filterWithKey
( \(FieldName k) _ ->
maybe True (elem k) (wantedFields headSet)
)
row
)
(rows tailSet)
}
-- | Make an lhs_fk IN (rhs_fk1, rhs_fk2, ..) expression list.
makeRelationshipIn :: DataLoaderPlan.Relationship -> Execute [Expression]
makeRelationshipIn
DataLoaderPlan.Relationship
{ leftRecordSet,
joinType = _,
rightTable = _rightTable
} = do
RecordSet {rows = _rows} <- getRecordSet leftRecordSet
-- TODO: A follow-up PR will add IN(..) and will join on the join
-- fields for the left/right tables. It needs support from Types.hs.
pure []
where
_lookupField' k row =
case OMap.lookup k row of
Nothing -> Nothing
Just x -> Just x
-- | Will be used by makeRelationshipIn for forming lhs_fk IN (rhs_fk1, rhs_fk2, ..)
planFieldNameToQueryFieldName :: EntityAlias -> FieldName -> MySQL.FieldName
planFieldNameToQueryFieldName (EntityAlias fieldNameEntity) (FieldName fieldName) =
MySQL.FieldName {fNameEntity = fieldNameEntity, fName = fieldName}
-- | Inefficient but clean left object join.
leftObjectJoin ::
Maybe [Text] ->
Text ->
[(DataLoaderPlan.FieldName, DataLoaderPlan.FieldName)] ->
RecordSet ->
RecordSet ->
Either ExecuteProblem RecordSet
leftObjectJoin wantedFields joinAlias joinFields left right =
pure
RecordSet
{ origin = Nothing,
wantedFields = Nothing,
rows =
V.fromList
[ joinObjectRows wantedFields joinAlias leftRow rightRows
| leftRow <- toList (rows left),
let rightRows =
V.fromList
[ rightRow
| rightRow <- toList (rows right),
not (null joinFields),
all
( \(rightField, leftField) ->
fromMaybe
False
( do
leftValue <-
OMap.lookup leftField leftRow
rightValue <-
OMap.lookup rightField rightRow
pure (leftValue == rightValue)
)
)
joinFields
]
]
}
-- | A naive, exponential reference implementation of a left join. It
-- serves as a trivial sample implementation for correctness checking
-- of more efficient ones.
leftArrayJoin ::
Maybe [Text] ->
Text ->
[(DataLoaderPlan.FieldName, DataLoaderPlan.FieldName)] ->
Top ->
Maybe Int ->
RecordSet ->
RecordSet ->
Either ExecuteProblem RecordSet
leftArrayJoin wantedFields joinAlias joinFields rhsTop rhsOffset left right =
pure
RecordSet
{ origin = Nothing,
wantedFields = Nothing,
rows =
V.fromList
[ joinArrayRows wantedFields joinAlias leftRow rightRows
| leftRow <- toList (rows left),
let rightRows =
V.fromList
( limit
( offset
[ rightRow
| rightRow <- toList (rows right),
not (null joinFields),
all
( \(rightField, leftField) ->
fromMaybe
False
( do
leftValue <-
OMap.lookup leftField leftRow
rightValue <-
OMap.lookup rightField rightRow
pure (leftValue == rightValue)
)
)
joinFields
]
)
)
]
}
where
offset = maybe id drop rhsOffset
limit =
case rhsTop of
NoTop -> id
Top n -> take n
-- | Join a row with another as an array join.
joinArrayRows ::
Maybe [Text] ->
Text ->
InsOrdHashMap DataLoaderPlan.FieldName OutputValue ->
Vector (InsOrdHashMap DataLoaderPlan.FieldName OutputValue) ->
InsOrdHashMap DataLoaderPlan.FieldName OutputValue
joinArrayRows wantedFields fieldName leftRow rightRow =
OMap.insert
(DataLoaderPlan.FieldName fieldName)
( ArrayOutputValue
( fmap
( RecordOutputValue
. OMap.filterWithKey
( \(DataLoaderPlan.FieldName k) _ ->
maybe True (elem k) wantedFields
)
)
rightRow
)
)
leftRow
-- | Join a row with another as an object join.
--
-- We expect rightRow to consist of a single row, but don't complain
-- if this is violated. TODO: Change?
joinObjectRows ::
Maybe [Text] ->
Text ->
InsOrdHashMap DataLoaderPlan.FieldName OutputValue ->
Vector (InsOrdHashMap DataLoaderPlan.FieldName OutputValue) ->
InsOrdHashMap DataLoaderPlan.FieldName OutputValue
joinObjectRows wantedFields fieldName leftRow rightRows =
foldl'
( \left row ->
OMap.insert
(DataLoaderPlan.FieldName fieldName)
( RecordOutputValue
( OMap.filterWithKey
(\(DataLoaderPlan.FieldName k) _ -> maybe True (elem k) wantedFields)
row
)
)
left
)
leftRow
rightRows