diff --git a/CHANGELOG.md b/CHANGELOG.md index bfba003a4249d..107beeeea4acf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Hasura GraphQL Engine Changelog +## Next release + +### Bug fixes and improvements + +(Add entries here in the order of: server, console, cli, docs, others) + +- server: fix mishandling of GeoJSON inputs in subscriptions (fix #3239) + ## `v1.2.0` Include the changelog from **v1.2.0-beta.1**, **v1.2.0-beta.2**, **v1.2.0-beta.3**, **v1.2.0-beta.4**, **v1.2.0-beta.5** diff --git a/server/src-lib/Hasura/GraphQL/Execute.hs b/server/src-lib/Hasura/GraphQL/Execute.hs index 6fe8fd2fd07c2..fe50268252d40 100644 --- a/server/src-lib/Hasura/GraphQL/Execute.hs +++ b/server/src-lib/Hasura/GraphQL/Execute.hs @@ -331,32 +331,6 @@ getMutOp ctx sqlGenCtx userInfo manager reqHeaders selSet = ordByCtx = _gOrdByCtx ctx insCtxMap = _gInsCtxMap ctx -getSubsOpM - :: ( MonadError QErr m - , MonadReader r m - , Has QueryCtxMap r - , Has FieldMap r - , Has OrdByCtx r - , Has SQLGenCtx r - , Has UserInfo r - , MonadIO m - , HasVersion - ) - => PGExecCtx - -> QueryReusability - -> VQ.Field - -> QueryActionExecuter - -> m (EL.LiveQueryPlan, Maybe EL.ReusableLiveQueryPlan) -getSubsOpM pgExecCtx initialReusability fld actionExecuter = - case VQ._fName fld of - "__typename" -> - throwVE "you cannot create a subscription on '__typename' field" - _ -> do - (astUnresolved, finalReusability) <- runReusabilityTWith initialReusability $ - GR.queryFldToPGAST fld actionExecuter - let varTypes = finalReusability ^? _Reusable - EL.buildLiveQueryPlan pgExecCtx (VQ._fAlias fld) astUnresolved varTypes - getSubsOp :: ( MonadError QErr m , MonadIO m @@ -368,10 +342,11 @@ getSubsOp -> UserInfo -> QueryReusability -> QueryActionExecuter - -> VQ.Field + -> VQ.SelSet -> m (EL.LiveQueryPlan, Maybe EL.ReusableLiveQueryPlan) -getSubsOp pgExecCtx gCtx sqlGenCtx userInfo queryReusability actionExecuter fld = - runE gCtx sqlGenCtx userInfo $ getSubsOpM pgExecCtx queryReusability fld actionExecuter +getSubsOp pgExecCtx gCtx sqlGenCtx userInfo queryReusability actionExecuter fields = + runE gCtx sqlGenCtx userInfo $ EL.buildLiveQueryPlan pgExecCtx queryReusability actionExecuter fields +-- runE gCtx sqlGenCtx userInfo $ getSubsOpM pgExecCtx queryReusability fld actionExecuter execRemoteGQ :: ( HasVersion diff --git a/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Plan.hs b/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Plan.hs index 9f446230d1e57..7ba7024e083c3 100644 --- a/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Plan.hs +++ b/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Plan.hs @@ -44,16 +44,21 @@ import Data.Has import Data.UUID (UUID) import qualified Hasura.GraphQL.Resolve as GR +import qualified Hasura.GraphQL.Resolve.Action as RA +import qualified Hasura.GraphQL.Resolve.Types as GR import qualified Hasura.GraphQL.Transport.HTTP.Protocol as GH import qualified Hasura.GraphQL.Validate as GV +import qualified Hasura.GraphQL.Validate.Types as GV import qualified Hasura.SQL.DML as S import Hasura.Db import Hasura.EncJSON +import Hasura.GraphQL.Utils import Hasura.RQL.Types import Hasura.SQL.Error import Hasura.SQL.Types import Hasura.SQL.Value +import Hasura.Server.Version (HasVersion) -- ------------------------------------------------------------------------------------------------- -- Multiplexed queries @@ -61,26 +66,39 @@ import Hasura.SQL.Value newtype MultiplexedQuery = MultiplexedQuery { unMultiplexedQuery :: Q.Query } deriving (Show, Eq, Hashable, J.ToJSON) -mkMultiplexedQuery :: Q.Query -> MultiplexedQuery -mkMultiplexedQuery baseQuery = - MultiplexedQuery . Q.fromText $ foldMap Q.getQueryText [queryPrefix, baseQuery, querySuffix] +mkMultiplexedQuery :: Map.HashMap G.Alias GR.QueryRootFldResolved -> MultiplexedQuery +mkMultiplexedQuery rootFields = MultiplexedQuery . Q.fromBuilder . toSQL $ S.mkSelect + { S.selExtr = + -- SELECT _subs.result_id, _fld_resp.root AS result + [ S.Extractor (mkQualIden (Iden "_subs") (Iden "result_id")) Nothing + , S.Extractor (mkQualIden (Iden "_fld_resp") (Iden "root")) (Just . S.Alias $ Iden "result") ] + , S.selFrom = Just $ S.FromExp [S.FIJoin $ + S.JoinExpr subsInputFromItem S.LeftOuter responseLateralFromItem (S.JoinOn $ S.BELit True)] + } where - queryPrefix = - [Q.sql| - select - _subs.result_id, _fld_resp.root as result - from - unnest( - $1::uuid[], $2::json[] - ) _subs (result_id, result_vars) - left outer join lateral - ( - |] - - querySuffix = - [Q.sql| - ) _fld_resp ON ('true') - |] + -- FROM unnest($1::uuid[], $2::json[]) _subs (result_id, result_vars) + subsInputFromItem = S.FIUnnest + [S.SEPrep 1 `S.SETyAnn` S.TypeAnn "uuid[]", S.SEPrep 2 `S.SETyAnn` S.TypeAnn "json[]"] + (S.Alias $ Iden "_subs") + [S.SEIden $ Iden "result_id", S.SEIden $ Iden "result_vars"] + + -- LEFT OUTER JOIN LATERAL ( ... ) _fld_resp + responseLateralFromItem = S.mkLateralFromItem selectRootFields (S.Alias $ Iden "_fld_resp") + selectRootFields = S.mkSelect + { S.selExtr = [S.Extractor rootFieldsJsonAggregate (Just . S.Alias $ Iden "root")] + , S.selFrom = Just . S.FromExp $ + flip map (Map.toList rootFields) $ \(fieldAlias, resolvedAST) -> + S.mkSelFromItem (GR.toSQLSelect resolvedAST) (S.Alias $ aliasToIden fieldAlias) + } + + -- json_build_object('field1', field1.root, 'field2', field2.root, ...) + rootFieldsJsonAggregate = S.SEFnApp "json_build_object" rootFieldsJsonPairs Nothing + rootFieldsJsonPairs = flip concatMap (Map.keys rootFields) $ \fieldAlias -> + [ S.SELit (G.unName $ G.unAlias fieldAlias) + , mkQualIden (aliasToIden fieldAlias) (Iden "root") ] + + mkQualIden prefix = S.SEQIden . S.QIden (S.QualIden prefix Nothing) -- TODO fix this Nothing of course + aliasToIden = Iden . G.unName . G.unAlias -- | Resolves an 'GR.UnresolvedVal' by converting 'GR.UVPG' values to SQL expressions that refer to -- the @result_vars@ input object, collecting variable values along the way. @@ -103,11 +121,13 @@ resolveMultiplexedValue = \case GR.UVSQL sqlExp -> pure sqlExp GR.UVSession -> pure $ fromResVars (PGTypeScalar PGJSON) ["session"] where - fromResVars ty jPath = - flip S.SETyAnn (S.mkTypeAnn ty) $ S.SEOpApp (S.SQLOp "#>>") + fromResVars pgType jPath = addTypeAnnotation pgType $ S.SEOpApp (S.SQLOp "#>>") [ S.SEQIden $ S.QIden (S.QualIden (Iden "_subs") Nothing) (Iden "result_vars") , S.SEArray $ map S.SELit jPath ] + addTypeAnnotation pgType = flip S.SETyAnn (S.mkTypeAnn pgType) . case pgType of + PGTypeScalar scalarType -> withConstructorFn scalarType + PGTypeArray _ -> id newtype CohortId = CohortId { unCohortId :: UUID } deriving (Show, Eq, Hashable, J.ToJSON, Q.FromCol) @@ -230,7 +250,6 @@ data LiveQueryPlan data ParameterizedLiveQueryPlan = ParameterizedLiveQueryPlan { _plqpRole :: !RoleName - , _plqpAlias :: !G.Alias , _plqpQuery :: !MultiplexedQuery } deriving (Show) $(J.deriveToJSON (J.aesonDrop 4 J.snakeCase) ''ParameterizedLiveQueryPlan) @@ -249,30 +268,41 @@ buildLiveQueryPlan :: ( MonadError QErr m , MonadReader r m , Has UserInfo r + , Has GR.FieldMap r + , Has GR.OrdByCtx r + , Has GR.QueryCtxMap r + , Has SQLGenCtx r + , HasVersion , MonadIO m ) => PGExecCtx - -> G.Alias - -> GR.QueryRootFldUnresolved - -> Maybe GV.ReusableVariableTypes + -> GV.QueryReusability + -> RA.QueryActionExecuter + -> GV.SelSet -> m (LiveQueryPlan, Maybe ReusableLiveQueryPlan) -buildLiveQueryPlan pgExecCtx fieldAlias astUnresolved varTypes = do - userInfo <- asks getter +buildLiveQueryPlan pgExecCtx initialReusability actionExecutioner fields = do + ((resolvedASTs, (queryVariableValues, syntheticVariableValues)), finalReusability) <- + GV.runReusabilityTWith initialReusability . flip runStateT mempty $ + fmap Map.fromList . for (toList fields) $ \field -> case GV._fName field of + "__typename" -> throwVE "you cannot create a subscription on '__typename' field" + _ -> do + unresolvedAST <- GR.queryFldToPGAST field actionExecutioner + resolvedAST <- GR.traverseQueryRootFldAST resolveMultiplexedValue unresolvedAST + pure (GV._fAlias field, resolvedAST) - (astResolved, (queryVariableValues, syntheticVariableValues)) <- flip runStateT mempty $ - GR.traverseQueryRootFldAST resolveMultiplexedValue astUnresolved - let pgQuery = mkMultiplexedQuery $ GR.toPGQuery astResolved + userInfo <- asks getter + let multiplexedQuery = mkMultiplexedQuery resolvedASTs roleName = _uiRole userInfo - parameterizedPlan = ParameterizedLiveQueryPlan roleName fieldAlias pgQuery + parameterizedPlan = ParameterizedLiveQueryPlan roleName multiplexedQuery - -- We need to ensure that the values provided for variables - -- are correct according to Postgres. Without this check - -- an invalid value for a variable for one instance of the - -- subscription will take down the entire multiplexed query + -- We need to ensure that the values provided for variables are correct according to Postgres. + -- Without this check an invalid value for a variable for one instance of the subscription will + -- take down the entire multiplexed query. validatedQueryVars <- validateVariables pgExecCtx queryVariableValues validatedSyntheticVars <- validateVariables pgExecCtx (toList syntheticVariableValues) let cohortVariables = CohortVariables (_uiSession userInfo) validatedQueryVars validatedSyntheticVars plan = LiveQueryPlan parameterizedPlan cohortVariables + varTypes = finalReusability ^? GV._Reusable reusablePlan = ReusableLiveQueryPlan parameterizedPlan validatedSyntheticVars <$> varTypes pure (plan, reusablePlan) diff --git a/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Poll.hs b/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Poll.hs index e30f9efc81933..2ccd37ff110c7 100644 --- a/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Poll.hs +++ b/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/Poll.hs @@ -44,7 +44,6 @@ import qualified Data.HashMap.Strict as Map import qualified Data.Time.Clock as Clock import qualified Data.UUID as UUID import qualified Data.UUID.V4 as UUID -import qualified Language.GraphQL.Draft.Syntax as G import qualified ListT import qualified StmContainers.Map as STMMap import qualified System.Metrics.Distribution as Metrics @@ -65,11 +64,7 @@ import Hasura.Session -- ------------------------------------------------------------------------------------------------- -- Subscribers -data Subscriber - = Subscriber - { _sRootAlias :: !G.Alias - , _sOnChangeCallback :: !OnChange - } +newtype Subscriber = Subscriber { _sOnChangeCallback :: OnChange } -- | live query onChange metadata, used for adding more extra analytics data data LiveQueryMetadata @@ -85,7 +80,6 @@ data LiveQueryResponse } type LGQResponse = GQResult LiveQueryResponse - type OnChange = LGQResponse -> IO () newtype SubscriberId = SubscriberId { _unSinkId :: UUID.UUID } @@ -183,7 +177,6 @@ data CohortSnapshot pushResultToCohort :: GQResult EncJSON - -- ^ a response that still needs to be wrapped with each 'Subscriber'’s root 'G.Alias' -> Maybe ResponseHash -> LiveQueryMetadata -> CohortSnapshot @@ -202,13 +195,8 @@ pushResultToCohort result !respHashM (LiveQueryMetadata dTime) cohortSnapshot = pushResultToSubscribers sinks where CohortSnapshot _ respRef curSinks newSinks = cohortSnapshot - pushResultToSubscribers = A.mapConcurrently_ $ \(Subscriber alias action) -> - let aliasText = G.unName $ G.unAlias alias - wrapWithAlias response = LiveQueryResponse - { _lqrPayload = encJToLBS $ encJFromAssocList [(aliasText, response)] - , _lqrExecutionTime = dTime - } - in action (wrapWithAlias <$> result) + response = result <&> \payload -> LiveQueryResponse (encJToLBS payload) dTime + pushResultToSubscribers = A.mapConcurrently_ $ \(Subscriber action) -> action response -- ------------------------------------------------------------------------------------------------- -- Pollers diff --git a/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/State.hs b/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/State.hs index d6572f32ddc2e..d08552038280e 100644 --- a/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/State.hs +++ b/server/src-lib/Hasura/GraphQL/Execute/LiveQuery/State.hs @@ -107,11 +107,11 @@ addLiveQuery logger lqState plan onResultAction = do where LiveQueriesState lqOpts pgExecCtx lqMap = lqState LiveQueriesOptions batchSize refetchInterval = lqOpts - LiveQueryPlan (ParameterizedLiveQueryPlan role alias query) cohortKey = plan + LiveQueryPlan (ParameterizedLiveQueryPlan role query) cohortKey = plan handlerId = PollerKey role query - !subscriber = Subscriber alias onResultAction + !subscriber = Subscriber onResultAction addToCohort sinkId handlerC = TMap.insert subscriber sinkId $ _cNewSubscribers handlerC diff --git a/server/src-lib/Hasura/GraphQL/Resolve.hs b/server/src-lib/Hasura/GraphQL/Resolve.hs index 8b4351ee1f4c0..ffce670bd441f 100644 --- a/server/src-lib/Hasura/GraphQL/Resolve.hs +++ b/server/src-lib/Hasura/GraphQL/Resolve.hs @@ -12,6 +12,7 @@ module Hasura.GraphQL.Resolve , QueryRootFldUnresolved , QueryRootFldResolved , toPGQuery + , toSQLSelect , RIntro.schemaR , RIntro.typeR @@ -68,12 +69,12 @@ traverseQueryRootFldAST f = \case toPGQuery :: QueryRootFldResolved -> Q.Query toPGQuery = \case - QRFPk s -> DS.selectQuerySQL DS.JASSingleObject s - QRFSimple s -> DS.selectQuerySQL DS.JASMultipleRows s - QRFAgg s -> DS.selectAggQuerySQL s - QRFActionSelect s -> DS.selectQuerySQL DS.JASSingleObject s - QRFActionExecuteObject s -> DS.selectQuerySQL DS.JASSingleObject s - QRFActionExecuteList s -> DS.selectQuerySQL DS.JASMultipleRows s + QRFPk s -> Q.fromBuilder $ toSQL $ DS.mkSQLSelect DS.JASSingleObject s + QRFSimple s -> Q.fromBuilder $ toSQL $ DS.mkSQLSelect DS.JASMultipleRows s + QRFAgg s -> Q.fromBuilder $ toSQL $ DS.mkAggSelect s + QRFActionSelect s -> Q.fromBuilder $ toSQL $ DS.mkSQLSelect DS.JASSingleObject s + QRFActionExecuteObject s -> Q.fromBuilder $ toSQL $ DS.mkSQLSelect DS.JASSingleObject s + QRFActionExecuteList s -> Q.fromBuilder $ toSQL $ DS.mkSQLSelect DS.JASMultipleRows s validateHdrs :: (Foldable t, QErrM m) => UserInfo -> t Text -> m () @@ -189,3 +190,12 @@ getOpCtx f = do opCtxMap <- asks getter onNothing (Map.lookup f opCtxMap) $ throw500 $ "lookup failed: opctx: " <> showName f + +toSQLSelect :: QueryRootFldResolved -> S.Select +toSQLSelect = \case + QRFPk s -> DS.mkSQLSelect DS.JASSingleObject s + QRFSimple s -> DS.mkSQLSelect DS.JASMultipleRows s + QRFAgg s -> DS.mkAggSelect s + QRFActionSelect s -> DS.mkSQLSelect DS.JASSingleObject s + QRFActionExecuteObject s -> DS.mkSQLSelect DS.JASSingleObject s + QRFActionExecuteList s -> DS.mkSQLSelect DS.JASSingleObject s diff --git a/server/src-lib/Hasura/GraphQL/Resolve/Action.hs b/server/src-lib/Hasura/GraphQL/Resolve/Action.hs index 9fca24d44dac9..c2dbecb858cf0 100644 --- a/server/src-lib/Hasura/GraphQL/Resolve/Action.hs +++ b/server/src-lib/Hasura/GraphQL/Resolve/Action.hs @@ -171,7 +171,7 @@ resolveActionMutationSync field executionContext sessionVariables = do (_fType field) $ _fSelSet field astResolved <- RS.traverseAnnSimpleSel resolveValTxt selectAstUnresolved let jsonAggType = mkJsonAggSelect outputType - return $ (,respHeaders) $ asSingleRowJsonResp (RS.selectQuerySQL jsonAggType astResolved) [] + return $ (,respHeaders) $ asSingleRowJsonResp (Q.fromBuilder $ toSQL $ RS.mkSQLSelect jsonAggType astResolved) [] where ActionExecutionContext actionName outputType outputFields definitionList resolvedWebhook confHeaders forwardClientHeaders = executionContext diff --git a/server/src-lib/Hasura/GraphQL/Validate.hs b/server/src-lib/Hasura/GraphQL/Validate.hs index d39f4c5bbc168..148141b6f9a8f 100644 --- a/server/src-lib/Hasura/GraphQL/Validate.hs +++ b/server/src-lib/Hasura/GraphQL/Validate.hs @@ -21,8 +21,8 @@ import Data.Has import qualified Data.HashMap.Strict as Map import qualified Data.HashSet as HS -import qualified Data.Sequence as Seq import qualified Language.GraphQL.Draft.Syntax as G +import qualified Data.Sequence as Seq import Hasura.GraphQL.Schema import Hasura.GraphQL.Transport.HTTP.Protocol @@ -58,8 +58,7 @@ getTypedOp opNameM selSets opDefs = throwVE $ "operationName cannot be used when " <> "an anonymous operation exists in the document" (Nothing, [selSet], []) -> - return $ G.TypedOperationDefinition - G.OperationTypeQuery Nothing [] [] selSet + return $ G.TypedOperationDefinition G.OperationTypeQuery Nothing [] [] selSet (Nothing, [], [opDef]) -> return opDef (Nothing, _, _) -> @@ -145,7 +144,7 @@ validateFrag (G.FragmentDefinition n onTy dirs selSet) = do data RootSelSet = RQuery !SelSet | RMutation !SelSet - | RSubscription !Field + | RSubscription !SelSet deriving (Show, Eq) validateGQ @@ -172,12 +171,15 @@ validateGQ (QueryParts opDef opRoot fragDefsL varValsM) = do G.OperationTypeQuery -> return $ RQuery selSet G.OperationTypeMutation -> return $ RMutation selSet G.OperationTypeSubscription -> - case Seq.viewl selSet of - Seq.EmptyL -> throw500 "empty selset for subscription" - fld Seq.:< rst -> do - unless (null rst) $ - throwVE "subscription must select only one top level field" - return $ RSubscription fld + case selSet of + Seq.Empty -> throw500 "empty selset for subscription" + (_ Seq.:<| rst) -> do + -- As an internal testing feature, we support subscribing to multiple + -- selection sets. First check if the corresponding directive is set. + let multipleAllowed = elem (G.Directive "_multiple_top_level_fields" []) (G._todDirectives opDef) + unless (multipleAllowed || null rst) $ + throwVE "subscriptions must select one top level field" + return $ RSubscription selSet isQueryInAllowlist :: GQLExecDoc -> HS.HashSet GQLQuery -> Bool isQueryInAllowlist q = HS.member gqlQuery diff --git a/server/src-lib/Hasura/GraphQL/Validate/Types.hs b/server/src-lib/Hasura/GraphQL/Validate/Types.hs index 83c7e8261867e..94cdd53b6b728 100644 --- a/server/src-lib/Hasura/GraphQL/Validate/Types.hs +++ b/server/src-lib/Hasura/GraphQL/Validate/Types.hs @@ -825,6 +825,9 @@ class (Monad m) => MonadReusability m where instance (MonadReusability m) => MonadReusability (ReaderT r m) where recordVariableUse a b = lift $ recordVariableUse a b markNotReusable = lift markNotReusable +instance (MonadReusability m) => MonadReusability (StateT s m) where + recordVariableUse a b = lift $ recordVariableUse a b + markNotReusable = lift markNotReusable newtype ReusabilityT m a = ReusabilityT { unReusabilityT :: StateT QueryReusability m a } deriving (Functor, Applicative, Monad, MonadError e, MonadReader r, MonadIO) diff --git a/server/src-lib/Hasura/RQL/DML/Select.hs b/server/src-lib/Hasura/RQL/DML/Select.hs index 9a21d761919c5..7e529d1bc3f6c 100644 --- a/server/src-lib/Hasura/RQL/DML/Select.hs +++ b/server/src-lib/Hasura/RQL/DML/Select.hs @@ -1,7 +1,5 @@ module Hasura.RQL.DML.Select ( selectP2 - , selectQuerySQL - , selectAggQuerySQL , convSelectQuery , asSingleRowJsonResp , module Hasura.RQL.DML.Select.Internal @@ -275,14 +273,6 @@ selectP2 jsonAggSelect (sel, p) = where selectSQL = toSQL $ mkSQLSelect jsonAggSelect sel -selectQuerySQL :: JsonAggSelect -> AnnSimpleSel -> Q.Query -selectQuerySQL jsonAggSelect sel = - Q.fromBuilder $ toSQL $ mkSQLSelect jsonAggSelect sel - -selectAggQuerySQL :: AnnAggSel -> Q.Query -selectAggQuerySQL = - Q.fromBuilder . toSQL . mkAggSelect - asSingleRowJsonResp :: Q.Query -> [Q.PrepArg] -> Q.TxE QErr EncJSON asSingleRowJsonResp query args = encJFromBS . runIdentity . Q.getRow diff --git a/server/src-lib/Hasura/RQL/DML/Select/Internal.hs b/server/src-lib/Hasura/RQL/DML/Select/Internal.hs index c6cd5b60121c0..ad23019b23440 100644 --- a/server/src-lib/Hasura/RQL/DML/Select/Internal.hs +++ b/server/src-lib/Hasura/RQL/DML/Select/Internal.hs @@ -715,7 +715,7 @@ baseNodeToSel joinCond baseNode = , S.selWhere = Just $ injectJoinCond joinCond whr } baseSelAls = S.Alias $ mkBaseTableAls pfx - baseFromItem = S.FISelect (S.Lateral False) baseSel baseSelAls + baseFromItem = S.mkSelFromItem baseSel baseSelAls -- function to create a joined from item from two from items leftOuterJoin current new = diff --git a/server/src-lib/Hasura/SQL/DML.hs b/server/src-lib/Hasura/SQL/DML.hs index a0ab184bcb3a7..bb56399b6bfc4 100644 --- a/server/src-lib/Hasura/SQL/DML.hs +++ b/server/src-lib/Hasura/SQL/DML.hs @@ -23,7 +23,10 @@ paren t = TB.char '(' <> t <> TB.char ')' data Select = Select - { selDistinct :: !(Maybe DistinctExpr) + { selCTEs :: ![(Alias, Select)] + -- ^ Unlike 'SelectWith', does not allow data-modifying statements (as those are only allowed at + -- the top level of a query). + , selDistinct :: !(Maybe DistinctExpr) , selExtr :: ![Extractor] , selFrom :: !(Maybe FromExp) , selWhere :: !(Maybe WhereFrag) @@ -37,7 +40,7 @@ instance NFData Select instance Cacheable Select mkSelect :: Select -mkSelect = Select Nothing [] Nothing +mkSelect = Select [] Nothing [] Nothing Nothing Nothing Nothing Nothing Nothing Nothing @@ -163,17 +166,20 @@ instance ToSQL WhereFrag where "WHERE" <-> paren (toSQL be) instance ToSQL Select where - toSQL sel = - "SELECT" - <-> toSQL (selDistinct sel) - <-> (", " <+> selExtr sel) - <-> toSQL (selFrom sel) - <-> toSQL (selWhere sel) - <-> toSQL (selGroupBy sel) - <-> toSQL (selHaving sel) - <-> toSQL (selOrderBy sel) - <-> toSQL (selLimit sel) - <-> toSQL (selOffset sel) + toSQL sel = case selCTEs sel of + [] -> "SELECT" + <-> toSQL (selDistinct sel) + <-> (", " <+> selExtr sel) + <-> toSQL (selFrom sel) + <-> toSQL (selWhere sel) + <-> toSQL (selGroupBy sel) + <-> toSQL (selHaving sel) + <-> toSQL (selOrderBy sel) + <-> toSQL (selLimit sel) + <-> toSQL (selOffset sel) + -- reuse SelectWith if there are any CTEs, since the generated SQL is the same + ctes -> toSQL $ SelectWith (map (CTESelect <$>) ctes) sel { selCTEs = [] } + mkSIdenExp :: (IsIden a) => a -> SQLExp mkSIdenExp = SEIden . toIden diff --git a/server/src-lib/Hasura/SQL/Rewrite.hs b/server/src-lib/Hasura/SQL/Rewrite.hs index 6235709b1ea18..016d6da9a695c 100644 --- a/server/src-lib/Hasura/SQL/Rewrite.hs +++ b/server/src-lib/Hasura/SQL/Rewrite.hs @@ -60,6 +60,7 @@ uSelect :: S.Select -> Uniq S.Select uSelect sel = do -- this has to be the first thing to process newFromM <- mapM uFromExp fromM + newCTEs <- for ctes $ \(alias, cte) -> (,) <$> addAlias alias <*> uSelect cte newWhereM <- forM whereM $ \(S.WhereFrag be) -> S.WhereFrag <$> uBoolExp be @@ -70,10 +71,10 @@ uSelect sel = do newOrdM <- mapM uOrderBy ordByM newDistM <- mapM uDistinct distM newExtrs <- mapM uExtractor extrs - return $ S.Select newDistM newExtrs newFromM newWhereM newGrpM + return $ S.Select newCTEs newDistM newExtrs newFromM newWhereM newGrpM newHavnM newOrdM limitM offM where - S.Select distM extrs fromM whereM grpM havnM ordByM limitM offM = sel + S.Select ctes distM extrs fromM whereM grpM havnM ordByM limitM offM = sel uDistinct = \case S.DistinctSimple -> return S.DistinctSimple S.DistinctOn l -> S.DistinctOn <$> mapM uSqlExp l diff --git a/server/tests-py/queries/graphql_query/boolexp/postgis/query_cast_geography_to_geometry.yaml b/server/tests-py/queries/graphql_query/boolexp/postgis/query_cast_geography_to_geometry.yaml index bc8f54de05ca6..58706869ef14b 100644 --- a/server/tests-py/queries/graphql_query/boolexp/postgis/query_cast_geography_to_geometry.yaml +++ b/server/tests-py/queries/graphql_query/boolexp/postgis/query_cast_geography_to_geometry.yaml @@ -7,7 +7,7 @@ response: - name: New York query: query: | - { + query { geog_table(where: { geog_col: { _cast: { diff --git a/server/tests-py/queries/graphql_query/boolexp/postgis/query_cast_geometry_to_geography.yaml b/server/tests-py/queries/graphql_query/boolexp/postgis/query_cast_geometry_to_geography.yaml index 0c646915ad7ff..8403e66c6167a 100644 --- a/server/tests-py/queries/graphql_query/boolexp/postgis/query_cast_geometry_to_geography.yaml +++ b/server/tests-py/queries/graphql_query/boolexp/postgis/query_cast_geometry_to_geography.yaml @@ -8,7 +8,7 @@ response: - name: Paris query: query: | - { + query { geog_as_geom_table(where: { geom_col: { _cast: { diff --git a/server/tests-py/queries/graphql_query/boolexp/postgis/query_illegal_cast_is_not_allowed.yaml b/server/tests-py/queries/graphql_query/boolexp/postgis/query_illegal_cast_is_not_allowed.yaml index 1c2acca96ec2a..04cf26877034b 100644 --- a/server/tests-py/queries/graphql_query/boolexp/postgis/query_illegal_cast_is_not_allowed.yaml +++ b/server/tests-py/queries/graphql_query/boolexp/postgis/query_illegal_cast_is_not_allowed.yaml @@ -9,7 +9,7 @@ response: message: 'field "integer" not found in type: ''geography_cast_exp''' query: query: | - { + query { geog_table(where: {geog_col: {_cast: {integer: {_eq: 0}}}}) { name } diff --git a/server/tests-py/test_graphql_queries.py b/server/tests-py/test_graphql_queries.py index ef1793f13ceed..41a3e82bcab12 100644 --- a/server/tests-py/test_graphql_queries.py +++ b/server/tests-py/test_graphql_queries.py @@ -389,7 +389,7 @@ def test_jsonb_has_keys_all_touchscreen_ram(self, hge_ctx, transport): def dir(cls): return 'queries/graphql_query/boolexp/jsonb' -@pytest.mark.parametrize("transport", ['http', 'websocket']) +@pytest.mark.parametrize("transport", ['http', 'websocket', 'subscription']) @usefixtures('per_class_tests_db_state') class TestGraphQLQueryBoolExpPostGIS: diff --git a/server/tests-py/validate.py b/server/tests-py/validate.py index fe5837a34a1a5..99553e62f56be 100644 --- a/server/tests-py/validate.py +++ b/server/tests-py/validate.py @@ -10,8 +10,10 @@ import base64 import jsondiff import jwt +import queue import random import warnings +import pytest from context import GQLWsClient, PytestConf @@ -194,27 +196,34 @@ def check_query(hge_ctx, conf, transport='http', add_auth=True, claims_namespace test_forbidden_when_admin_secret_reqd(hge_ctx, conf) headers['X-Hasura-Admin-Secret'] = hge_ctx.hge_key - assert transport in ['websocket', 'http'], "Unknown transport type " + transport - if transport == 'websocket': - assert 'response' in conf - assert conf['url'].endswith('/graphql') - print('running on websocket') - return validate_gql_ws_q( - hge_ctx, - conf['url'], - conf['query'], - headers, - conf['response'], - True - ) - elif transport == 'http': + assert transport in ['http', 'websocket', 'subscription'], "Unknown transport type " + transport + if transport == 'http': print('running on http') return validate_http_anyq(hge_ctx, conf['url'], conf['query'], headers, conf['status'], conf.get('response')) + elif transport == 'websocket': + print('running on websocket') + return validate_gql_ws_q(hge_ctx, conf, headers, retry=True) + elif transport == 'subscription': + print('running via subscription') + return validate_gql_ws_q(hge_ctx, conf, headers, retry=True, via_subscription=True) + + +def validate_gql_ws_q(hge_ctx, conf, headers, retry=False, via_subscription=False): + assert 'response' in conf + assert conf['url'].endswith('/graphql') + endpoint = conf['url'] + query = conf['query'] + exp_http_response = conf['response'] + + if via_subscription: + query_text = query['query'] + assert query_text.startswith('query '), query_text + # make the query into a subscription and add the + # _multiple_subscriptions directive that enables having more + # than 1 root field in a subscription + query['query'] = 'subscription' + query_text[len('query'):].replace("{"," @_multiple_top_level_fields {",1) - - -def validate_gql_ws_q(hge_ctx, endpoint, query, headers, exp_http_response, retry=False): if endpoint == '/v1alpha1/graphql': ws_client = GQLWsClient(hge_ctx, '/v1alpha1/graphql') else: @@ -223,7 +232,7 @@ def validate_gql_ws_q(hge_ctx, endpoint, query, headers, exp_http_response, retr if not headers or len(headers) == 0: ws_client.init({}) - query_resp = ws_client.send_query(query, headers=headers, timeout=15) + query_resp = ws_client.send_query(query, query_id='hge_test', headers=headers, timeout=15) resp = next(query_resp) print('websocket resp: ', resp) @@ -240,10 +249,15 @@ def validate_gql_ws_q(hge_ctx, endpoint, query, headers, exp_http_response, retr assert resp['type'] in ['data', 'error'], resp else: assert resp['type'] == 'data', resp - assert 'payload' in resp, resp - resp_done = next(query_resp) - assert resp_done['type'] == 'complete' + + if via_subscription: + ws_client.send({ 'id': 'hge_test', 'type': 'stop' }) + with pytest.raises(queue.Empty): + ws_client.get_ws_event(0) + else: + resp_done = next(query_resp) + assert resp_done['type'] == 'complete' return assert_graphql_resp_expected(resp['payload'], exp_http_response, query, skip_if_err_msg=hge_ctx.avoid_err_msg_checks)