graphql-engine/server/src-lib/Hasura/Backends/MySQL/DataLoader/Plan.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

315 lines
10 KiB
Haskell

{-# LANGUAGE DuplicateRecordFields #-}
-- | Make a plan for the data loader to execute (.Execute).
--
-- It will produce a graph of actions, to be executed by .Execute.
module Hasura.Backends.MySQL.DataLoader.Plan
( Ref,
PlannedAction (..),
Action (..),
Select (..),
Join (..),
Relationship (..),
FieldName (..),
HeadAndTail (..),
toFieldName,
runPlan,
planSelectHeadAndTail,
actionsForest,
selectQuery,
)
where
import Data.Aeson
import Data.Bifunctor
import Data.Graph
import Data.Sequence qualified as Seq
import Data.String
import Hasura.Backends.MySQL.Types qualified as MySQL
import Hasura.Prelude hiding (head, second, tail, tell)
--------------------------------------------------------------------------------
-- Types
-- | A reference to a result of loading a recordset from the database.
data Ref = Ref
{ -- | This index will be generated by the planner.
idx :: !Int,
-- | A display name. The idx gives us uniqueness.
text :: !Text
}
deriving (Show, Eq, Generic, Ord)
instance Hashable Ref
-- | A almost-the-same version of Select from Types.Internal, except
-- with some fields used for planning and executing.
data Select = Select
{ selectAggUnwrap :: !(Maybe Text),
selectFrom :: !(Maybe MySQL.From),
selectGroupBy :: ![MySQL.FieldName],
selectHaskellJoins :: ![MySQL.Join],
selectOrderBy :: !(Maybe (NonEmpty MySQL.OrderBy)),
selectProjections :: ![MySQL.Projection],
selectRelationship :: !(Maybe Relationship),
selectWhere :: !MySQL.Where,
selectWantedFields :: !(Maybe [Text])
}
deriving (Show)
-- | An join action.
data Join = Join
{ -- | Join this side...
leftRecordSet :: Ref,
-- | with this side.
rightRecordSet :: Ref,
-- | Join only the top N results. It's important that we do this
-- IN HASKELL, therefore this is not part of the generated SQL.
joinRhsTop :: !MySQL.Top,
-- | Offset applied to the right-hand-side table.
joinRhsOffset :: !(Maybe Int),
-- | Type of relational join to do.
joinType :: !MySQL.JoinType,
-- | Field name to return the join result as; e.g. "albums" for an
-- artist with an array relation of albums.
joinFieldName :: !Text,
-- | The SQL queries may achieve the data using joining fields,
-- but those fields aren't supposed to be returned back to the
-- user. To avoid that, we explicitly specify which fields are
-- wanted from this join. E.g. "title" and "year", but not
-- artist_id which was used to Haskell-join the row with an
-- album_artist_id, or whatever.
wantedFields :: !(Maybe [Text])
}
deriving (Show)
-- | An action that the executor will perform. Either pull data from
-- the database directly via a select, or join two other actions'
-- record sets together.
data Action
= SelectAction Select
| JoinAction Join
deriving (Show)
-- | An action planned, with a unique reference. I.e. the @action@
-- performed yields a result stored at reference @ref@.
data PlannedAction = PlannedAction
{ ref :: Ref,
action :: Action
}
deriving (Show)
-- | A relationship lets the executor insert on-the-fly WHERE
-- fkey1=fkey2 for relationships. These can only be inserted
-- on-the-fly and aren't known at the time of planning, because the
-- keys come from the left-hand-side table for a join.
data Relationship = Relationship
{ leftRecordSet :: Ref,
rightTable :: MySQL.EntityAlias,
joinType :: MySQL.JoinType
}
deriving (Show)
-- | Just a wrapper to clarify some types. It's different from the
-- MySQL.FieldName because it doesn't care about schemas: schemas
-- aren't returned in recordsets from the database.
newtype FieldName
= FieldName Text
deriving (Show, Ord, Eq, Hashable, FromJSON, ToJSONKey, IsString)
-- | The reason for this is subtle. Read this documentation. For each
-- join on a select (see above, there is a list), we split that out
-- into three jobs:
--
-- 1. One job for the left hand side (i.e. the select).
-- 2. One job for the right hand side (i.e. the join).
-- 3. One job to join them (And in the darkness bind them...)
--
-- This is performed as a fold, like: @foldM planJoin head joins@. A
-- nice linked-list or tree-like structure arises. The planner code
-- produces a graph out of this; so it's possible that some
-- parallelism can be achieved by running multiple jobs at once.
--
-- The "head" is the first, original select. The "tail" is the
-- (indirectly) linked list of joins. That list may also be empty. In
-- that case, the tail is simply the same as the head.
--
-- If the tail is different to the head, then we choose the tail, as
-- it represents the joined up version of both. If they're the same,
-- we take whichever.
data HeadAndTail = HeadAndTail
{ head :: Ref,
tail :: Ref
}
-- | We're simply accumulating a set of actions with this. The counter
-- lets us generate unique refs.
data PlanState = PlanState
{ actions :: !(Seq PlannedAction),
counter :: !Int
}
-- | Simple monad to collect actions.
newtype Plan a = Plan
{ unPlan :: State PlanState a
}
deriving (Functor, Applicative, Monad, MonadState PlanState)
--------------------------------------------------------------------------------
-- Conversions
-- | Note that we're intentionally discarding the table qualification.
toFieldName :: MySQL.FieldName -> FieldName
toFieldName (MySQL.FieldName {fName = t}) = FieldName t
joinAliasName :: MySQL.EntityAlias -> Text
joinAliasName (MySQL.EntityAlias {entityAliasText}) = entityAliasText
-- | Used for display purposes, not semantic content.
selectFromName :: Maybe MySQL.From -> Text
selectFromName = \case
Nothing -> ""
Just x -> case x of
MySQL.FromQualifiedTable (MySQL.Aliased {aliasedThing = MySQL.TableName {name}}) -> name
-- This constructor will be removed in the subsequent PR.
MySQL.FromOpenJson {} -> ""
--------------------------------------------------------------------------------
-- Run planner
runPlan :: Plan r -> (r, [PlannedAction])
runPlan =
second (toList . actions)
. flip runState (PlanState {actions = mempty, counter = 0})
. unPlan
--------------------------------------------------------------------------------
-- Planners
-- | See the documentation for 'HeadAndTail'.
planSelectHeadAndTail :: Maybe Relationship -> Maybe Text -> MySQL.Select -> Plan HeadAndTail
planSelectHeadAndTail relationship joinExtractPath select0 = do
ref <- generate (selectFromName (MySQL.selectFrom select0))
let select = fromSelect relationship joinExtractPath select0
action = SelectAction select
tell PlannedAction {ref, action}
joinsFinalRef <- foldM planJoin ref (selectHaskellJoins select)
pure
( let head = ref
tail = case selectHaskellJoins select of
[] -> ref
_ -> joinsFinalRef
in HeadAndTail {head, tail}
)
-- | Given a left-hand-side table and a join spec, produce a single
-- reference that refers to the composition of the two.
planJoin :: Ref -> MySQL.Join -> Plan Ref
planJoin leftRecordSet join' = do
ref <- generate (joinAliasName (MySQL.joinRightTable join'))
rightRecordSet <-
fmap
(\HeadAndTail {..} -> tail)
( planSelectHeadAndTail
( Just
( Relationship
{ leftRecordSet,
joinType = MySQL.joinType join',
rightTable = MySQL.joinRightTable join'
}
)
)
Nothing
(MySQL.joinSelect join')
)
let action =
JoinAction
Join
{ leftRecordSet,
rightRecordSet,
wantedFields = MySQL.selectFinalWantedFields (MySQL.joinSelect join'),
joinRhsTop = MySQL.joinTop join',
joinRhsOffset = MySQL.joinOffset join',
joinFieldName = MySQL.joinFieldName join',
joinType = MySQL.joinType join',
..
}
tell PlannedAction {ref, action}
pure ref
--------------------------------------------------------------------------------
-- Monad helpers
-- | Write the planned action to the state, like a writer's @tell@.
tell :: PlannedAction -> Plan ()
tell action = modify' (\s -> s {actions = actions s Seq.:|> action})
-- | Generate a unique reference with a label for debugging.
generate :: Text -> Plan Ref
generate text = do
idx <- gets counter
modify' (\s -> s {counter = counter s + 1})
pure (Ref {idx, text})
--------------------------------------------------------------------------------
-- Graphing the plan to a forest
-- | Graph the set of planned actions ready for execution in the correct order.
actionsForest :: (Graph -> Graph) -> [PlannedAction] -> Forest PlannedAction
actionsForest transform actions =
let (graph, vertex2Node, _key2Vertex) =
graphFromEdges
( map
( \PlannedAction {ref, action} ->
( action,
ref,
map
(\PlannedAction {ref = r} -> r)
(filter (elem ref . plannedActionRefs) actions)
)
)
actions
)
in fmap
( fmap
((\(action, ref, _refs) -> PlannedAction {ref, action}) . vertex2Node)
)
(dff (transform graph))
where
plannedActionRefs PlannedAction {action} =
case action of
SelectAction Select {selectRelationship} ->
case selectRelationship of
Just Relationship {leftRecordSet} -> [leftRecordSet]
Nothing -> mempty
JoinAction Join {leftRecordSet, rightRecordSet} ->
[leftRecordSet, rightRecordSet]
--------------------------------------------------------------------------------
-- Build a query
-- | Used by the executor to produce a plain old select that can be
-- sent to the MySQL server.
selectQuery :: Select -> MySQL.Select
selectQuery Select {..} =
MySQL.Select
{ selectJoins = selectHaskellJoins,
selectProjections = selectProjections,
selectTop = MySQL.NoTop,
selectOffset = Nothing,
selectFor = MySQL.NoFor,
..
}
-- | From a plain select, and possibly a parent/left-hand-side
-- relationship, produce a select that is useful for execution.
fromSelect :: Maybe Relationship -> Maybe Text -> MySQL.Select -> Select
fromSelect selectRelationship selectAggUnwrap select@MySQL.Select {..} =
Select
{ selectHaskellJoins = selectJoins,
selectWantedFields = MySQL.selectFinalWantedFields select,
selectGroupBy = [],
selectProjections = toList selectProjections,
..
}