diff --git a/src/PostgREST/Private/QueryFragment.hs b/src/PostgREST/Private/QueryFragment.hs index 737eaa5e2..c987380df 100644 --- a/src/PostgREST/Private/QueryFragment.hs +++ b/src/PostgREST/Private/QueryFragment.hs @@ -18,6 +18,10 @@ import qualified Data.Text as T (intercalate, takeWhile, toLower) import qualified Hasql.DynamicStatements.Snippet as H +import PostgREST.RangeQuery (NonnegRange, + allRange, + rangeLimit, + rangeOffset) import PostgREST.Types import Protolude hiding (cast, intercalate, @@ -28,6 +32,8 @@ import Text.InterpolatedString.Perl6 (qc) import qualified Hasql.Encoders as HE +import Data.Foldable (foldr1) + noLocationF :: SqlFragment noLocationF = "array[]::text[]" @@ -117,20 +123,24 @@ pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment pgFmtColumn table "*" = fromQi table <> ".*" pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c -pgFmtField :: QualifiedIdentifier -> Field -> SqlFragment -pgFmtField table (c, jp) = pgFmtColumn table c <> pgFmtJsonPath jp +pgFmtField :: QualifiedIdentifier -> Field -> H.Snippet +pgFmtField table (c, jp) = H.sql (pgFmtColumn table c) <> pgFmtJsonPath jp -pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment -pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> pgFmtAs fName jp alias -pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> encodeUtf8 cast <> " )" <> pgFmtAs fName jp alias +pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> H.Snippet +pgFmtSelectItem table (f@(fName, jp), Nothing, alias, _) = pgFmtField table f <> H.sql (pgFmtAs fName jp alias) +-- Ideally we'd quote the cast with "pgFmtIdent cast". However, that would invalidate common casts such as "int", "bigint", etc. +-- Try doing: `select 1::"bigint"` - it'll err, using "int8" will work though. There's some parser magic that pg does that's invalidated when quoting. +-- Not quoting should be fine, we validate the input on Parsers. +pgFmtSelectItem table (f@(fName, jp), Just cast, alias, _) = "CAST (" <> pgFmtField table f <> " AS " <> H.sql (encodeUtf8 cast) <> " )" <> H.sql (pgFmtAs fName jp alias) -pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> SqlFragment -pgFmtOrderTerm qi ot = BS.unwords [ - pgFmtField qi $ otTerm ot, - BS.pack $ maybe mempty show $ otDirection ot, - BS.pack $ maybe mempty show $ otNullOrder ot] +pgFmtOrderTerm :: QualifiedIdentifier -> OrderTerm -> H.Snippet +pgFmtOrderTerm qi ot = + pgFmtField qi (otTerm ot) <> " " <> + H.sql (BS.unwords [ + BS.pack $ maybe mempty show $ otDirection ot, + BS.pack $ maybe mempty show $ otNullOrder ot]) -pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment +pgFmtFilter :: QualifiedIdentifier -> Filter -> H.Snippet pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper of Op op val -> pgFmtFieldOp op <> " " <> case op of "like" -> unknownLiteral (T.map star val) @@ -138,47 +148,49 @@ pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper "is" -> whiteList val _ -> unknownLiteral val + -- We don't use "IN", we use "= ANY". IN has the following disadvantages: + -- + No way to use an empty value on IN: "col IN ()" is invalid syntax. With ANY we can do "= ANY('{}')" + -- + Can invalidate prepared statements: multiple parameters on an IN($1, $2, $3) will lead to using different prepared statements and not take advantage of caching. In vals -> pgFmtField table fld <> " " <> - let emptyValForIn = "= any('{}') " in -- Workaround because for postgresql "col IN ()" is invalid syntax, we instead do "col = any('{}')" - case (&&) (length vals == 1) . T.null <$> headMay vals of - Just False -> sqlOperator "in" <> "(" <> BS.intercalate ", " (unknownLiteral <$> vals) <> ") " - Just True -> emptyValForIn - Nothing -> emptyValForIn + case vals of + [""] -> "= ANY('{}') " + -- Here we build the pg array, e.g '{"Hebdon, John","Other","Another"}', manually. We quote the values to prevent the "," being treated as an element separator. + -- TODO: Ideally this would be done on Hasql with an encoder, but the "array unknown" is not working(Hasql doesn't pass any value). + _ -> "= ANY (" <> unknownLiteral ("{" <> T.intercalate "," ((\x -> "\"" <> x <> "\"") <$> vals) <> "}") <> ")" Fts op lang val -> - pgFmtFieldOp op - <> "(" - <> maybe mempty ((<> ", ") . pgFmtLit) lang - <> unknownLiteral val - <> ") " + pgFmtFieldOp op <> "(" <> ftsLang lang <> unknownLiteral val <> ") " where + ftsLang = maybe mempty (\l -> unknownLiteral l <> ", ") pgFmtFieldOp op = pgFmtField table fld <> " " <> sqlOperator op - sqlOperator o = HM.lookupDefault "=" o operators + sqlOperator o = H.sql $ HM.lookupDefault "=" o operators notOp = if hasNot then "NOT" else mempty star c = if c == '*' then '%' else c - unknownLiteral = (<> "::unknown ") . pgFmtLit - whiteList :: Text -> SqlFragment - whiteList v = maybe + -- IS cannot be prepared. `PREPARE boolplan AS SELECT * FROM projects where id IS $1` will give a syntax error. + -- The above can be fixed by using `PREPARE boolplan AS SELECT * FROM projects where id IS NOT DISTINCT FROM $1;` + -- However that would not accept the TRUE/FALSE/NULL keywords. See: https://stackoverflow.com/questions/6133525/proper-way-to-set-preparedstatement-parameter-to-null-under-postgres. + whiteList :: Text -> H.Snippet + whiteList v = H.sql $ maybe (pgFmtLit v <> "::unknown") encodeUtf8 (find ((==) . T.toLower $ v) ["null","true","false"]) -pgFmtJoinCondition :: JoinCondition -> SqlFragment +pgFmtJoinCondition :: JoinCondition -> H.Snippet pgFmtJoinCondition (JoinCondition (qi1, col1) (qi2, col2)) = - pgFmtColumn qi1 col1 <> " = " <> pgFmtColumn qi2 col2 + H.sql $ pgFmtColumn qi1 col1 <> " = " <> pgFmtColumn qi2 col2 -pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> SqlFragment -pgFmtLogicTree qi (Expr hasNot op forest) = notOp <> " (" <> BS.intercalate (" " <> BS.pack (show op) <> " ") (pgFmtLogicTree qi <$> forest) <> ")" +pgFmtLogicTree :: QualifiedIdentifier -> LogicTree -> H.Snippet +pgFmtLogicTree qi (Expr hasNot op forest) = H.sql notOp <> " (" <> intercalateSnippet (" " <> BS.pack (show op) <> " ") (pgFmtLogicTree qi <$> forest) <> ")" where notOp = if hasNot then "NOT" else mempty pgFmtLogicTree qi (Stmnt flt) = pgFmtFilter qi flt -pgFmtJsonPath :: JsonPath -> SqlFragment +pgFmtJsonPath :: JsonPath -> H.Snippet pgFmtJsonPath = \case [] -> mempty (JArrow x:xs) -> "->" <> pgFmtJsonOperand x <> pgFmtJsonPath xs (J2Arrow x:xs) -> "->>" <> pgFmtJsonOperand x <> pgFmtJsonPath xs where - pgFmtJsonOperand (JKey k) = pgFmtLit k - pgFmtJsonOperand (JIdx i) = pgFmtLit i <> "::int" + pgFmtJsonOperand (JKey k) = unknownLiteral k + pgFmtJsonOperand (JIdx i) = unknownLiteral i <> "::int" pgFmtAs :: FieldName -> JsonPath -> Maybe Alias -> SqlFragment pgFmtAs _ [] Nothing = mempty @@ -192,7 +204,7 @@ pgFmtAs fName jp Nothing = case jOp <$> lastMay jp of Nothing -> mempty pgFmtAs _ _ (Just alias) = " AS " <> pgFmtIdent alias -countF :: SqlQuery -> Bool -> (SqlFragment, SqlFragment) +countF :: H.Snippet -> Bool -> (H.Snippet, SqlFragment) countF countQuery shouldCount = if shouldCount then ( @@ -208,6 +220,13 @@ returningF qi returnings = then "RETURNING 1" -- For mutation cases where there's no ?select, we return 1 to know how many rows were modified else "RETURNING " <> BS.intercalate ", " (pgFmtColumn qi <$> returnings) +limitOffsetF :: NonnegRange -> H.Snippet +limitOffsetF range = + ("LIMIT " <> limit <> " OFFSET " <> offset) `emptySnippetOnFalse` (range == allRange) + where + limit = maybe "ALL" (\l -> unknownEncoder (BS.pack $ show l)) $ rangeLimit range + offset = unknownEncoder (BS.pack . show $ rangeOffset range) + responseHeadersF :: PgVersion -> SqlFragment responseHeadersF pgVer = if pgVer >= pgVersion96 @@ -224,3 +243,17 @@ currentSettingF :: Text -> SqlFragment currentSettingF setting = -- nullif is used because of https://gist.github.com/steve-chavez/8d7033ea5655096903f3b52f8ed09a15 "nullif(current_setting(" <> pgFmtLit setting <> ", true), '')" + +-- Hasql Snippet utilitarians +unknownEncoder :: ByteString -> H.Snippet +unknownEncoder = H.encoderAndParam (HE.nonNullable HE.unknown) + +unknownLiteral :: Text -> H.Snippet +unknownLiteral = unknownEncoder . encodeUtf8 + +emptySnippetOnFalse :: H.Snippet -> Bool -> H.Snippet +emptySnippetOnFalse val cond = if cond then mempty else val + +intercalateSnippet :: SqlFragment -> [H.Snippet] -> H.Snippet +intercalateSnippet _ [] = mempty +intercalateSnippet frag snippets = foldr1 (\a b -> a <> H.sql frag <> b) snippets diff --git a/src/PostgREST/QueryBuilder.hs b/src/PostgREST/QueryBuilder.hs index 22983f9b7..7d6f58cb4 100644 --- a/src/PostgREST/QueryBuilder.hs +++ b/src/PostgREST/QueryBuilder.hs @@ -27,59 +27,54 @@ import qualified Hasql.DynamicStatements.Snippet as H import Data.Tree (Tree (..)) import Data.Maybe - import PostgREST.Private.QueryFragment -import PostgREST.RangeQuery (allRange, rangeLimit, - rangeOffset) import PostgREST.Types import Protolude hiding (cast, intercalate, replace) -readRequestToQuery :: ReadRequest -> SqlQuery +readRequestToQuery :: ReadRequest -> H.Snippet readRequestToQuery (Node (Select colSelects mainQi tblAlias implJoins logicForest joinConditions_ ordts range, _) forest) = - BS.unwords [ - "SELECT " <> BS.intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects), - "FROM " <> BS.intercalate ", " (tabl : implJs), - BS.unwords joins, - ("WHERE " <> BS.intercalate " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_)) - `emptyOnFalse` (null logicForest && null joinConditions_), - ("ORDER BY " <> BS.intercalate ", " (map (pgFmtOrderTerm qi) ordts)) `emptyOnFalse` null ordts, - ("LIMIT " <> maybe "ALL" (BS.pack . show) (rangeLimit range) <> " OFFSET " <> (BS.pack . show) (rangeOffset range)) `emptyOnFalse` (range == allRange) - ] + "SELECT " <> + intercalateSnippet ", " ((pgFmtSelectItem qi <$> colSelects) ++ selects) <> + "FROM " <> H.sql (BS.intercalate ", " (tabl : implJs)) <> " " <> + intercalateSnippet " " joins <> " " <> + ("WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest ++ map pgFmtJoinCondition joinConditions_)) + `emptySnippetOnFalse` (null logicForest && null joinConditions_) <> " " <> + (("ORDER BY " <> intercalateSnippet ", " (map (pgFmtOrderTerm qi) ordts)) `emptySnippetOnFalse` null ordts) <> " " <> + limitOffsetF range where implJs = fromQi <$> implJoins tabl = fromQi mainQi <> maybe mempty (\a -> " AS " <> pgFmtIdent a) tblAlias qi = maybe mainQi (QualifiedIdentifier mempty) tblAlias (joins, selects) = foldr getJoinsSelects ([],[]) forest -getJoinsSelects :: ReadRequest -> ([SqlFragment], [SqlFragment]) -> ([SqlFragment], [SqlFragment]) +getJoinsSelects :: ReadRequest -> ([H.Snippet], [H.Snippet]) -> ([H.Snippet], [H.Snippet]) getJoinsSelects rr@(Node (_, (name, Just Relation{relType=relTyp,relTable=Table{tableName=table}}, alias, _, _)) _) (j,s) = let subquery = readRequestToQuery rr in case relTyp of M2O -> let aliasOrName = fromMaybe name alias localTableName = pgFmtIdent $ table <> "_" <> aliasOrName - sel = "row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName - joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> localTableName <> " ON TRUE " in + sel = H.sql ("row_to_json(" <> localTableName <> ".*) AS " <> pgFmtIdent aliasOrName) + joi = " LEFT JOIN LATERAL( " <> subquery <> " ) AS " <> H.sql localTableName <> " ON TRUE " in (joi:j,sel:s) _ -> let sel = "COALESCE ((" - <> "SELECT json_agg(" <> pgFmtIdent table <> ".*) " - <> "FROM (" <> subquery <> ") " <> pgFmtIdent table - <> "), '[]') AS " <> pgFmtIdent (fromMaybe name alias) in + <> "SELECT json_agg(" <> H.sql (pgFmtIdent table) <> ".*) " + <> "FROM (" <> subquery <> ") " <> H.sql (pgFmtIdent table) <> " " + <> "), '[]') AS " <> H.sql (pgFmtIdent (fromMaybe name alias)) in (j,sel:s) getJoinsSelects (Node (_, (_, Nothing, _, _, _)) _) _ = ([], []) mutateRequestToQuery :: MutateRequest -> H.Snippet mutateRequestToQuery (Insert mainQi iCols body onConflct putConditions returnings) = - "WITH " <> normalizedBody body <> + "WITH " <> normalizedBody body <> " " <> + "INSERT INTO " <> H.sql (fromQi mainQi) <> H.sql (if S.null iCols then " " else "(" <> cols <> ") ") <> + "SELECT " <> H.sql cols <> " " <> + H.sql ("FROM json_populate_recordset (null::" <> fromQi mainQi <> ", " <> selectBody <> ") _ ") <> + -- Only used for PUT + ("WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "_") <$> putConditions)) `emptySnippetOnFalse` null putConditions <> H.sql (BS.unwords [ - "INSERT INTO ", fromQi mainQi, if S.null iCols then " " else "(" <> cols <> ")", - BS.unwords [ - "SELECT " <> cols <> " FROM", - "json_populate_recordset", "(null::", fromQi mainQi, ", " <> selectBody <> ") _", - -- Only used for PUT - ("WHERE " <> BS.intercalate " AND " (pgFmtLogicTree (QualifiedIdentifier mempty "_") <$> putConditions)) `emptyOnFalse` null putConditions], maybe "" (\(oncDo, oncCols) -> ( "ON CONFLICT(" <> BS.intercalate ", " (pgFmtIdent <$> oncCols) <> ") " <> case oncDo of IgnoreDuplicates -> @@ -100,13 +95,11 @@ mutateRequestToQuery (Update mainQi uCols body logicForest returnings) = -- the select has to be based on "returnings" to make computed overloaded functions not throw then H.sql ("SELECT " <> emptyBodyReturnedColumns <> " FROM " <> fromQi mainQi <> " WHERE false") else - "WITH " <> normalizedBody body <> - H.sql (BS.unwords [ - "UPDATE " <> fromQi mainQi <> " SET " <> cols, - "FROM (SELECT * FROM json_populate_recordset", "(null::", fromQi mainQi, ", " <> selectBody <> ")) _ ", - ("WHERE " <> BS.intercalate " AND " (pgFmtLogicTree mainQi <$> logicForest)) `emptyOnFalse` null logicForest, - returningF mainQi returnings - ]) + "WITH " <> normalizedBody body <> " " <> + "UPDATE " <> H.sql (fromQi mainQi) <> " SET " <> H.sql cols <> " " <> + "FROM (SELECT * FROM json_populate_recordset (null::" <> H.sql (fromQi mainQi) <> " , " <> H.sql selectBody <> " )) _ " <> + ("WHERE " <> intercalateSnippet " AND " (pgFmtLogicTree mainQi <$> logicForest)) `emptySnippetOnFalse` null logicForest <> " " <> + H.sql (returningF mainQi returnings) where cols = BS.intercalate ", " (pgFmtIdent <> const " = _." <> pgFmtIdent <$> S.toList uCols) emptyBodyReturnedColumns :: SqlFragment @@ -114,11 +107,9 @@ mutateRequestToQuery (Update mainQi uCols body logicForest returnings) = | null returnings = "NULL" | otherwise = BS.intercalate ", " (pgFmtColumn (QualifiedIdentifier mempty $ qiName mainQi) <$> returnings) mutateRequestToQuery (Delete mainQi logicForest returnings) = - H.sql $ BS.unwords [ - "DELETE FROM ", fromQi mainQi, - ("WHERE " <> BS.intercalate " AND " (map (pgFmtLogicTree mainQi) logicForest)) `emptyOnFalse` null logicForest, - returningF mainQi returnings - ] + "DELETE FROM " <> H.sql (fromQi mainQi) <> " " <> + ("WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree mainQi) logicForest)) `emptySnippetOnFalse` null logicForest <> " " <> + H.sql (returningF mainQi returnings) requestToCallProcQuery :: QualifiedIdentifier -> [PgArg] -> Maybe PayloadJSON -> Bool -> Maybe PreferParameters -> [FieldName] -> H.Snippet requestToCallProcQuery qi pgArgs pj returnsScalar preferParams returnings = @@ -174,16 +165,13 @@ requestToCallProcQuery qi pgArgs pj returnsScalar preferParams returnings = -- It only takes WHERE into account and doesn't include LIMIT/OFFSET because it would reduce the COUNT. -- SELECT 1 is done instead of SELECT * to prevent doing expensive operations(like functions based on the columns) -- inside the FROM target. -readRequestToCountQuery :: ReadRequest -> SqlQuery +readRequestToCountQuery :: ReadRequest -> H.Snippet readRequestToCountQuery (Node (Select{from=qi, where_=logicForest}, _) _) = - BS.unwords [ - "SELECT 1", - "FROM " <> fromQi qi, - ("WHERE " <> BS.intercalate " AND " (map (pgFmtLogicTree qi) logicForest)) `emptyOnFalse` null logicForest - ] + "SELECT 1 " <> "FROM " <> H.sql (fromQi qi) <> " " <> + ("WHERE " <> intercalateSnippet " AND " (map (pgFmtLogicTree qi) logicForest)) `emptySnippetOnFalse` null logicForest -limitedQuery :: SqlQuery -> Maybe Integer -> SqlQuery -limitedQuery query maxRows = query <> maybe mempty (\x -> " LIMIT " <> BS.pack (show x)) maxRows +limitedQuery :: H.Snippet -> Maybe Integer -> H.Snippet +limitedQuery query maxRows = query <> H.sql (maybe mempty (\x -> " LIMIT " <> BS.pack (show x)) maxRows) setLocalQuery :: Text -> (Text, Text) -> SqlQuery setLocalQuery prefix (k, v) = diff --git a/src/PostgREST/Statements.hs b/src/PostgREST/Statements.hs index 597b4b21f..3e91f36d0 100644 --- a/src/PostgREST/Statements.hs +++ b/src/PostgREST/Statements.hs @@ -24,7 +24,6 @@ import qualified Data.ByteString.Char8 as BS import Data.Maybe import Data.Text.Read (decimal) import qualified Hasql.Decoders as HD -import qualified Hasql.Encoders as HE import qualified Hasql.Statement as H import Network.HTTP.Types.Status import PostgREST.Error @@ -34,7 +33,6 @@ import PostgREST.Types import Protolude hiding (cast, replace, toS) import Protolude.Conv (toS) -import Text.InterpolatedString.Perl6 (qc) import qualified Hasql.DynamicStatements.Snippet as H import qualified Hasql.DynamicStatements.Statement as H @@ -45,7 +43,7 @@ import qualified Hasql.DynamicStatements.Statement as H -} type ResultsWithCount = (Maybe Int64, Int64, [BS.ByteString], BS.ByteString, Either SimpleError [GucHeader], Either SimpleError (Maybe Status)) -createWriteStatement :: SqlQuery -> H.Snippet -> Bool -> Bool -> Bool -> +createWriteStatement :: H.Snippet -> H.Snippet -> Bool -> Bool -> Bool -> PreferRepresentation -> [Text] -> PgVersion -> H.Statement () ResultsWithCount createWriteStatement selectQuery mutateQuery wantSingle isInsert asCsv rep pKeys pgVer = @@ -60,9 +58,9 @@ createWriteStatement selectQuery mutateQuery wantSingle isInsert asCsv rep pKeys locF <> " AS header, " <> bodyF <> " AS body, " <> responseHeadersF pgVer <> " AS response_headers, " <> - responseStatusF pgVer <> " AS response_status " <> + responseStatusF pgVer <> " AS response_status " + ) <> "FROM (" <> selectF <> ") _postgrest_t" - ) locF = if isInsert && rep `elem` [Full, HeadersOnly] @@ -81,30 +79,30 @@ createWriteStatement selectQuery mutateQuery wantSingle isInsert asCsv rep pKeys selectF -- prevent using any of the column names in ?select= when no response is returned from the CTE - | rep `elem` [None, HeadersOnly] = "SELECT * FROM " <> sourceCTEName + | rep `elem` [None, HeadersOnly] = H.sql ("SELECT * FROM " <> sourceCTEName) | otherwise = selectQuery decodeStandard :: HD.Result ResultsWithCount decodeStandard = fromMaybe (Nothing, 0, [], mempty, Right [], Right Nothing) <$> HD.rowMaybe standardRow -createReadStatement :: SqlQuery -> SqlQuery -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion -> +createReadStatement :: H.Snippet -> H.Snippet -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion -> H.Statement () ResultsWithCount createReadStatement selectQuery countQuery isSingle countTotal asCsv binaryField pgVer = - H.Statement sql HE.noParams decodeStandard False + H.dynamicallyParameterized snippet decodeStandard True where - sql = [qc| - WITH - {sourceCTEName} AS ({selectQuery}) - {countCTEF} - SELECT - {countResultF} AS total_result_set, - pg_catalog.count(_postgrest_t) AS page_total, - {noLocationF} AS header, - {bodyF} AS body, - {responseHeadersF pgVer} AS response_headers, - {responseStatusF pgVer} AS response_status - FROM ( SELECT * FROM {sourceCTEName}) _postgrest_t |] + snippet = + "WITH " <> + H.sql sourceCTEName <> " AS ( " <> selectQuery <> " ) " <> + countCTEF <> " " <> + H.sql ("SELECT " <> + countResultF <> " AS total_result_set, " <> + "pg_catalog.count(_postgrest_t) AS page_total, " <> + noLocationF <> " AS header, " <> + bodyF <> " AS body, " <> + responseHeadersF pgVer <> " AS response_headers, " <> + responseStatusF pgVer <> " AS response_status " <> + "FROM ( SELECT * FROM " <> sourceCTEName <> " ) _postgrest_t") (countCTEF, countResultF) = countF countQuery countTotal @@ -130,7 +128,7 @@ standardRow = (,,,,,) <$> nullableColumn HD.int8 <*> column HD.int8 type ProcResults = (Maybe Int64, Int64, ByteString, Either SimpleError [GucHeader], Either SimpleError (Maybe Status)) -callProcStatement :: Bool -> H.Snippet -> SqlQuery -> SqlQuery -> Bool -> +callProcStatement :: Bool -> H.Snippet -> H.Snippet -> H.Snippet -> Bool -> Bool -> Bool -> Bool -> Bool -> Maybe FieldName -> PgVersion -> H.Statement () ProcResults callProcStatement returnsScalar callProcQuery selectQuery countQuery countTotal isSingle asCsv asBinary multObjects binaryField pgVer = @@ -138,15 +136,15 @@ callProcStatement returnsScalar callProcQuery selectQuery countQuery countTotal where snippet = "WITH " <> H.sql sourceCTEName <> " AS (" <> callProcQuery <> ") " <> - H.sql ( countCTEF <> + H.sql ( "SELECT " <> countResultF <> " AS total_result_set, " <> "pg_catalog.count(_postgrest_t) AS page_total, " <> bodyF <> " AS body, " <> responseHeadersF pgVer <> " AS response_headers, " <> - responseStatusF pgVer <> " AS response_status " <> - "FROM (" <> selectQuery <> ") _postgrest_t") + responseStatusF pgVer <> " AS response_status ") <> + "FROM (" <> selectQuery <> ") _postgrest_t" (countCTEF, countResultF) = countF countQuery countTotal @@ -173,11 +171,11 @@ callProcStatement returnsScalar callProcQuery selectQuery countQuery countTotal <*> (fromMaybe defGucHeaders <$> nullableColumn decodeGucHeaders) <*> (fromMaybe defGucStatus <$> nullableColumn decodeGucStatus) -createExplainStatement :: SqlQuery -> H.Statement () (Maybe Int64) +createExplainStatement :: H.Snippet -> H.Statement () (Maybe Int64) createExplainStatement countQuery = - H.Statement sql HE.noParams decodeExplain False + H.dynamicallyParameterized snippet decodeExplain True where - sql = [qc| EXPLAIN (FORMAT JSON) {countQuery} |] + snippet = "EXPLAIN (FORMAT JSON) " <> countQuery -- | -- An `EXPLAIN (FORMAT JSON) select * from items;` output looks like this: -- [{ diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs index f0ae64532..241e5b2a1 100644 --- a/test/Feature/QuerySpec.hs +++ b/test/Feature/QuerySpec.hs @@ -846,7 +846,7 @@ spec actualPgVersion = do [json| [{"name":"Hebdon, John"},{"name":"Williams, Mary"},{"name":"Smith, Joseph"}] |] { matchHeaders = [matchContentTypeJson] } get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\")" `shouldRespondWith` - [json| [{"name":"David White"},{"name":"Larry Thompson"}] |] + [json| [{"name":"David White"},{"name":"Larry Thompson"},{"name":"Double O Seven(007)"}] |] { matchHeaders = [matchContentTypeJson] } it "succeeds w/ and w/o quoted values" $ do @@ -854,16 +854,16 @@ spec actualPgVersion = do [json| [{"name":"Hebdon, John"},{"name":"David White"}] |] { matchHeaders = [matchContentTypeJson] } get "/w_or_wo_comma_names?name=not.in.(\"Hebdon, John\",Larry Thompson,\"Smith, Joseph\")" `shouldRespondWith` - [json| [{"name":"Williams, Mary"},{"name":"David White"}] |] + [json| [{"name":"Williams, Mary"},{"name":"David White"},{"name":"Double O Seven(007)"}] |] + { matchHeaders = [matchContentTypeJson] } + get "/w_or_wo_comma_names?name=in.(\"Double O Seven(007)\")" `shouldRespondWith` + [json| [{"name":"Double O Seven(007)"}] |] { matchHeaders = [matchContentTypeJson] } - it "checks well formed quoted values" $ do - get "/w_or_wo_comma_names?name=in.(\"\"Hebdon, John\")" `shouldRespondWith` - [json| [] |] { matchHeaders = [matchContentTypeJson] } - get "/w_or_wo_comma_names?name=in.(\"\"Hebdon, John\"\"Mary)" `shouldRespondWith` - [json| [] |] { matchHeaders = [matchContentTypeJson] } - get "/w_or_wo_comma_names?name=in.(Williams\"Hebdon, John\")" `shouldRespondWith` - [json| [] |] { matchHeaders = [matchContentTypeJson] } + it "fails on malformed quoted values" $ do + get "/w_or_wo_comma_names?name=in.(\"\"Hebdon, John\")" `shouldRespondWith` 400 + get "/w_or_wo_comma_names?name=in.(\"\"Hebdon, John\"\"Mary)" `shouldRespondWith` 400 + get "/w_or_wo_comma_names?name=in.(Williams\"Hebdon, John\")" `shouldRespondWith` 400 describe "IN and NOT IN empty set" $ do context "returns an empty result for IN when no value is present" $ do diff --git a/test/fixtures/data.sql b/test/fixtures/data.sql index 3e080a130..85a7cf702 100644 --- a/test/fixtures/data.sql +++ b/test/fixtures/data.sql @@ -344,6 +344,7 @@ INSERT INTO w_or_wo_comma_names VALUES ('Williams, Mary'); INSERT INTO w_or_wo_comma_names VALUES ('Smith, Joseph'); INSERT INTO w_or_wo_comma_names VALUES ('David White'); INSERT INTO w_or_wo_comma_names VALUES ('Larry Thompson'); +INSERT INTO w_or_wo_comma_names VALUES ('Double O Seven(007)'); TRUNCATE TABLE items_with_different_col_types CASCADE; INSERT INTO items_with_different_col_types VALUES (1, null, null, null, null, null, null, null);