diff --git a/CHANGELOG.md b/CHANGELOG.md index 089725c3d..c06563b6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Accept clients requesting `Content-Type: application/json` from / - @feynmanliang - #493, Updating with empty JSON object makes zero updates @koulakis - Make HTTP headers and cookies available as GUCs #800 - @ruslantalpa +- #701, Ability to quote values on IN filters - @steve-chavez +- #641, Allow IN filter to have no values - @steve-chavez ### Fixed - #827, Avoid Warp reaper, extend socket timeout to 1 hour - @majorcode diff --git a/src/PostgREST/App.hs b/src/PostgREST/App.hs index b2ef314eb..be1774c37 100644 --- a/src/PostgREST/App.hs +++ b/src/PostgREST/App.hs @@ -266,7 +266,6 @@ app dbStructure conf apiRequest = filterPk sc table pk = sc == (tableSchema . pkTable) pk && table == (tableName . pkTable) pk filterCol :: Schema -> TableName -> Column -> Bool filterCol sc tb Column{colTable=Table{tableSchema=s, tableName=t}} = s==sc && t==tb - filterCol _ _ _ = False allPrKeys = dbPrimaryKeys dbStructure allOrigins = ("Access-Control-Allow-Origin", "*") :: Header jsonH = toHeader CTApplicationJSON diff --git a/src/PostgREST/OpenAPI.hs b/src/PostgREST/OpenAPI.hs index f20a1411a..ac1df669d 100644 --- a/src/PostgREST/OpenAPI.hs +++ b/src/PostgREST/OpenAPI.hs @@ -22,9 +22,8 @@ import Data.Swagger import PostgREST.ApiRequest (ContentType(..)) import PostgREST.Config (prettyVersion) -import PostgREST.QueryBuilder (operators) import PostgREST.Types (Table(..), Column(..), PgArg(..), - Proxy(..), ProcDescription(..), toMime) + Proxy(..), ProcDescription(..), toMime, Operator(..)) makeMimeList :: [ContentType] -> MimeList makeMimeList cs = MimeList $ map (fromString . toS . toMime) cs @@ -73,7 +72,7 @@ makeOperatorPattern = intercalate "|" [ concat ["^", x, y, "[.]"] | x <- ["not[.]", ""], - y <- map fst operators ] + y <- map show [Equals ..] ] makeRowFilter :: Column -> Param makeRowFilter c = diff --git a/src/PostgREST/Parsers.hs b/src/PostgREST/Parsers.hs index aa1714ab4..bef28972b 100644 --- a/src/PostgREST/Parsers.hs +++ b/src/PostgREST/Parsers.hs @@ -2,13 +2,14 @@ module PostgREST.Parsers where import Protolude hiding (try, intercalate) import Control.Monad ((>>)) +import Data.Foldable (foldl1) import Data.Text (intercalate, replace, strip) import Data.List (init, last) import Data.Tree import Data.Either.Combinators (mapLeft) -import PostgREST.QueryBuilder (operators) import PostgREST.Types import Text.ParserCombinators.Parsec hiding (many, (<|>)) +import Text.Read (read) import PostgREST.RangeQuery (NonnegRange,allRange) import Text.Parsec.Error @@ -17,14 +18,12 @@ pRequestSelect rootName selStr = mapError $ parse (pReadRequest rootName) ("failed to parse select parameter (" <> toS selStr <> ")") (toS selStr) pRequestFilter :: (Text, Text) -> Either ApiRequestError (Path, Filter) -pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> op <*> val) +pRequestFilter (k, v) = mapError $ (,) <$> path <*> (Filter <$> fld <*> oper) where treePath = parse pTreePath ("failed to parser tree path (" ++ toS k ++ ")") $ toS k - opVal = parse pOpValueExp ("failed to parse filter (" ++ toS v ++ ")") $ toS v + oper = parse pOperation ("failed to parse filter (" ++ toS v ++ ")") $ toS v path = fst <$> treePath fld = snd <$> treePath - op = fst <$> opVal - val = snd <$> opVal pRequestOrder :: (Text, Text) -> Either ApiRequestError (Path, [OrderTerm]) pRequestOrder (k, v) = mapError $ (,) <$> path <*> ord' @@ -120,22 +119,29 @@ pSelect = lexeme $ s <- pStar return ((s, Nothing), Nothing, Nothing) -pOperator :: Parser Operator -pOperator = toS <$> (pOp "operator (eq, gt, ...)") - where pOp = foldl (<|>) empty $ map (try . string . toS . fst) operators +pOperation :: Parser Operation +pOperation = try ( string "not" *> pDelimiter *> (Operation True <$> pExpr)) <|> Operation False <$> pExpr + where + pExpr :: Parser (Operator, Operand) + pExpr = + ((,) <$> (read <$> foldl1 (<|>) (try . string . show <$> notInOps)) <*> (pDelimiter *> pVText)) + <|> try (string (show In) *> pDelimiter *> ((,) <$> pure In <*> pVTextL)) + <|> try (string (show NotIn) *> pDelimiter *> ((,) <$> pure NotIn <*> pVTextL)) + "operator (eq, gt, ...)" + notInOps = [Equals .. Contained] -pValue :: Parser FValue -pValue = VText <$> (toS <$> many anyChar) +pVText :: Parser Operand +pVText = VText . toS <$> many anyChar + +pVTextL :: Parser Operand +pVTextL = VTextL <$> pLValue `sepBy1` char ',' + where + pLValue :: Parser Text + pLValue = toS <$> (try (char '"' *> many (noneOf "\"") <* char '"' <* notFollowedBy (noneOf ",") ) <|> many (noneOf ",")) pDelimiter :: Parser Char pDelimiter = char '.' "delimiter (.)" -pOperatiorWithNegation :: Parser Operator -pOperatiorWithNegation = try ( (<>) <$> ( toS <$> string "not." ) <*> pOperator) <|> pOperator - -pOpValueExp :: Parser (Operator, FValue) -pOpValueExp = (,) <$> pOperatiorWithNegation <*> (pDelimiter *> pValue) - pOrder :: Parser [OrderTerm] pOrder = lexeme pOrderTerm `sepBy` char ',' diff --git a/src/PostgREST/QueryBuilder.hs b/src/PostgREST/QueryBuilder.hs index 62f1679a8..92772356e 100644 --- a/src/PostgREST/QueryBuilder.hs +++ b/src/PostgREST/QueryBuilder.hs @@ -16,7 +16,6 @@ module PostgREST.QueryBuilder ( , createReadStatement , createWriteStatement , getJoinConditions - , operators , pgFmtIdent , pgFmtLit , requestToQuery @@ -37,13 +36,12 @@ import PostgREST.RangeQuery (NonnegRange, rangeLimit, rangeOffset, import Data.Functor.Contravariant (contramap) import qualified Data.HashMap.Strict as HM import Data.Maybe -import Data.Text (intercalate, unwords, replace, isInfixOf, toLower, split) +import Data.Text (intercalate, unwords, replace, isInfixOf, toLower) import qualified Data.Text as T (map, takeWhile, null) import qualified Data.Text.Encoding as T import Data.Tree (Tree(..)) import qualified Data.Vector as V import PostgREST.Types -import qualified Data.Map as M import Text.InterpolatedString.Perl6 (qc) import qualified Data.ByteString.Char8 as BS import Data.Scientific ( FPFormat (..) @@ -182,25 +180,6 @@ callProc qi params selectQuery countQuery _ countTotal isSingle paramsAsJson = | isSingle = asJsonSingleF | otherwise = asJsonF -operators :: [(Text, SqlFragment)] -operators = [ - ("eq", "="), - ("gte", ">="), -- has to be before gt (parsers) - ("gt", ">"), - ("lte", "<="), -- has to be before lt (parsers) - ("lt", "<"), - ("neq", "<>"), - ("like", "like"), - ("ilike", "ilike"), - ("in", "in"), - ("notin", "not in"), - ("isnot", "is not"), -- has to be before is (parsers) - ("is", "is"), - ("@@", "@@"), - ("@>", "@>"), - ("<@", "<@") - ] - pgFmtIdent :: SqlFragment -> SqlFragment pgFmtIdent x = "\"" <> replace "\"" "\"\"" (trimNullChars $ toS x) <> "\"" @@ -219,31 +198,27 @@ requestToCountQuery schema (DbRead (Node (Select _ _ conditions _ _, (mainTbl, _ unwords [ "SELECT pg_catalog.count(*)", "FROM ", fromQi qi, - ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi) localConditions )) `emptyOnNull` localConditions + ("WHERE " <> intercalate " AND " ( map (pgFmtFilter qi) localConditions )) `emptyOnNull` localConditions ] where - qi = if mainTbl == sourceCTEName - then QualifiedIdentifier "" mainTbl - else QualifiedIdentifier schema mainTbl - fn Filter{value=VText _} = True - fn Filter{value=VForeignKey _ _} = False + qi = removeSourceCTESchema schema mainTbl + fn Filter{operation=Operation{expr=(_, VText _)}} = True + fn Filter{operation=Operation{expr=(_, VTextL _)}} = True + fn Filter{operation=Operation{expr=(_, VForeignKey _ _)}} = False localConditions = filter fn conditions requestToQuery :: Schema -> Bool -> DbRequest -> SqlQuery requestToQuery schema isParent (DbRead (Node (Select colSelects tbls conditions ord range, (nodeName, maybeRelation, _)) forest)) = query where - -- TODO! the following helper functions are just to remove the "schema" part when the table is "source" which is the name - -- of our WITH query part mainTbl = fromMaybe nodeName (tableName . relTable <$> maybeRelation) - tblSchema tbl = if tbl == sourceCTEName then "" else schema - qi = QualifiedIdentifier (tblSchema mainTbl) mainTbl - toQi t = QualifiedIdentifier (tblSchema t) t + qi = removeSourceCTESchema schema mainTbl + toQi = removeSourceCTESchema schema query = unwords [ "SELECT ", intercalate ", " (map (pgFmtSelectItem qi) colSelects ++ selects), "FROM ", intercalate ", " (map (fromQi . toQi) tbls), unwords joins, - ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions, + ("WHERE " <> intercalate " AND " ( map (pgFmtFilter qi ) conditions )) `emptyOnNull` conditions, orderF (fromMaybe [] ord), if isParent then "" else limitF range ] @@ -272,11 +247,11 @@ requestToQuery schema isParent (DbRead (Node (Select colSelects tbls conditions where node_name = fromMaybe name alias local_table_name = table <> "_" <> node_name - replaceTableName localTableName (Filter a b (VForeignKey (QualifiedIdentifier "" _) c)) = Filter a b (VForeignKey (QualifiedIdentifier "" localTableName) c) + replaceTableName localTableName (Filter a (Operation b (c, VForeignKey (QualifiedIdentifier "" _) d))) = Filter a (Operation b (c, VForeignKey (QualifiedIdentifier "" localTableName) d)) replaceTableName _ x = x sel = "row_to_json(" <> pgFmtIdent local_table_name <> ".*) AS " <> pgFmtIdent node_name joi = " LEFT OUTER JOIN ( " <> subquery <> " ) AS " <> pgFmtIdent local_table_name <> - " ON " <> intercalate " AND " ( map (pgFmtCondition qi . replaceTableName local_table_name) (getJoinConditions r) ) + " ON " <> intercalate " AND " ( map (pgFmtFilter qi . replaceTableName local_table_name) (getJoinConditions r) ) where subquery = requestToQuery schema True (DbRead (Node n forst)) getQueryParts (Node n@(_, (name, Just Relation{relType=Many,relTable=Table{tableName=table}}, alias)) forst) (j,s) = (j,sel:s) where @@ -312,7 +287,7 @@ requestToQuery schema _ (DbMutate (Update mainTbl (PayloadJSON rows) conditions unwords [ "UPDATE ", fromQi qi, " SET " <> intercalate "," assignments <> " ", - ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions, + ("WHERE " <> intercalate " AND " ( map (pgFmtFilter qi ) conditions )) `emptyOnNull` conditions, ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnNull` returnings ] Nothing -> undefined @@ -324,13 +299,16 @@ requestToQuery schema _ (DbMutate (Delete mainTbl conditions returnings)) = qi = QualifiedIdentifier schema mainTbl query = unwords [ "DELETE FROM ", fromQi qi, - ("WHERE " <> intercalate " AND " ( map (pgFmtCondition qi ) conditions )) `emptyOnNull` conditions, + ("WHERE " <> intercalate " AND " ( map (pgFmtFilter qi ) conditions )) `emptyOnNull` conditions, ("RETURNING " <> intercalate ", " (map (pgFmtColumn qi) returnings)) `emptyOnNull` returnings ] sourceCTEName :: SqlFragment sourceCTEName = "pg_source" +removeSourceCTESchema :: Schema -> TableName -> QualifiedIdentifier +removeSourceCTESchema schema tbl = QualifiedIdentifier (if tbl == sourceCTEName then "" else schema) tbl + unquoted :: JSON.Value -> Text unquoted (JSON.String t) = t unquoted (JSON.Number n) = @@ -401,7 +379,7 @@ getJoinConditions (Relation t cols ft fcs typ lt lc1 lc2) = ftN = tableName ft ltN = fromMaybe "" (tableName <$> lt) toFilter :: Text -> Text -> Column -> Column -> Filter - toFilter tb ftb c fc = Filter (colName c, Nothing) "=" (VForeignKey (QualifiedIdentifier s tb) (ForeignKey fc{colTable=(colTable fc){tableName=ftb}})) + toFilter tb ftb c fc = Filter (colName c, Nothing) (Operation False (Equals, VForeignKey (QualifiedIdentifier s tb) (ForeignKey fc{colTable=(colTable fc){tableName=ftb}}))) unicodeStatement :: Text -> HE.Params a -> HD.Result b -> Bool -> H.Query a b unicodeStatement = H.statement . T.encodeUtf8 @@ -417,11 +395,6 @@ insertableValueWithType :: Text -> JSON.Value -> SqlFragment insertableValueWithType t v = pgFmtLit (unquoted v) <> "::" <> t -whiteList :: Text -> SqlFragment -whiteList val = fromMaybe - (toS (pgFmtLit val) <> "::unknown ") - (find ((==) . toLower $ val) ["null","true","false"]) - pgFmtColumn :: QualifiedIdentifier -> Text -> SqlFragment pgFmtColumn table "*" = fromQi table <> ".*" pgFmtColumn table c = fromQi table <> "." <> pgFmtIdent c @@ -433,45 +406,40 @@ pgFmtSelectItem :: QualifiedIdentifier -> SelectItem -> SqlFragment pgFmtSelectItem table (f@(_, jp), Nothing, alias) = pgFmtField table f <> pgFmtAs jp alias pgFmtSelectItem table (f@(_, jp), Just cast, alias) = "CAST (" <> pgFmtField table f <> " AS " <> cast <> " )" <> pgFmtAs jp alias -pgFmtCondition :: QualifiedIdentifier -> Filter -> SqlFragment -pgFmtCondition table (Filter (col,jp) ops val) = - notOp <> " " <> sqlCol <> " " <> pgFmtOperator opCode <> " " <> - if opCode `elem` ["is","isnot"] then whiteList (getInner val) else sqlValue +pgFmtFilter :: QualifiedIdentifier -> Filter -> SqlFragment +pgFmtFilter table (Filter fld (Operation hasNot_ ex@(op, operand))) = notOp <> " " <> case operand of + VForeignKey fQi (ForeignKey Column{colTable=Table{tableName=fTableName}, colName=fColName}) -> + pgFmtField fQi fld <> " " <> opToSqlFragment op <> " " <> pgFmtColumn (removeSourceCTESchema (qiSchema fQi) fTableName) fColName + _ -> pgFmtField table fld <> " " <> pgFmtExpr ex where - headPredicate:rest = split (=='.') ops - hasNot caseTrue caseFalse = if headPredicate == "not" then caseTrue else caseFalse - opCode = hasNot (headDef "eq" rest) headPredicate - notOp = hasNot headPredicate "" - sqlCol = case val of - VText _ -> pgFmtColumn table col <> pgFmtJsonPath jp - VForeignKey qi _ -> pgFmtColumn qi col - sqlValue = valToStr val - getInner v = case v of - VText s -> s - _ -> "" - valToStr v = case v of - VText s -> pgFmtValue opCode s - VForeignKey (QualifiedIdentifier s _) (ForeignKey Column{colTable=Table{tableName=ft}, colName=fc}) -> pgFmtColumn qi fc - where qi = QualifiedIdentifier (if ft == sourceCTEName then "" else s) ft - _ -> "" + notOp = if hasNot_ then "NOT" else "" -pgFmtValue :: Text -> Text -> SqlFragment -pgFmtValue opCode val = - case opCode of - "like" -> unknownLiteral $ T.map star val - "ilike" -> unknownLiteral $ T.map star val - "in" -> "(" <> intercalate ", " (map unknownLiteral $ split (==',') val) <> ") " - "notin" -> "(" <> intercalate ", " (map unknownLiteral $ split (==',') val) <> ") " - "@@" -> "to_tsquery(" <> unknownLiteral val <> ") " - _ -> unknownLiteral val +pgFmtExpr :: (Operator, Operand) -> SqlFragment +pgFmtExpr ex = + case ex of + (Like, VText val) -> opToSqlFragment Like <> " " <> unknownLiteral (T.map star val) + (ILike, VText val) -> opToSqlFragment ILike <> " " <> unknownLiteral (T.map star val) + (TSearch, VText val) -> opToSqlFragment TSearch <> " " <> "to_tsquery(" <> unknownLiteral val <> ") " + (Is, VText val) -> opToSqlFragment Is <> " " <> whiteList val + (In, VTextL vals) -> exprForIn vals + (NotIn, VTextL vals) -> opToSqlFragment NotIn <> " " <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") " + (op, VText val) -> opToSqlFragment op <> " " <> unknownLiteral val + _ -> "" -- should not happen, all possible combinations are defined in Parsers where star c = if c == '*' then '%' else c unknownLiteral = (<> "::unknown ") . pgFmtLit - -pgFmtOperator :: Text -> SqlFragment -pgFmtOperator opCode = fromMaybe "=" $ M.lookup opCode operatorsMap - where - operatorsMap = M.fromList operators + whiteList :: Text -> SqlFragment + whiteList v = fromMaybe + (toS (pgFmtLit v) <> "::unknown ") + (find ((==) . toLower $ v) ["null","true","false"]) + exprForIn :: [Text] -> SqlFragment + exprForIn vals = + let emptyValForIn = "= any('{}') " in + case T.null <$> headMay vals of + Just isNull -> if isNull && length vals == 1 + then emptyValForIn + else opToSqlFragment In <> " " <> "(" <> intercalate ", " (map unknownLiteral vals) <> ") " + Nothing -> emptyValForIn pgFmtJsonPath :: Maybe JsonPath -> SqlFragment pgFmtJsonPath (Just [x]) = "->>" <> pgFmtLit x diff --git a/src/PostgREST/Types.hs b/src/PostgREST/Types.hs index 8b760e849..3fb0f2204 100644 --- a/src/PostgREST/Types.hs +++ b/src/PostgREST/Types.hs @@ -1,6 +1,7 @@ module PostgREST.Types where import Protolude import qualified GHC.Show +import qualified GHC.Read import Data.Aeson import qualified Data.ByteString.Lazy as BL import Data.HashMap.Strict as M @@ -78,9 +79,7 @@ data Column = , colDefault :: Maybe Text , colEnum :: [Text] , colFK :: Maybe ForeignKey - } - | Star { colTable :: Table } - deriving (Show, Ord) + } deriving (Show, Ord) type Synonym = (Column,Column) @@ -138,8 +137,66 @@ data Proxy = Proxy { , proxyPath :: Text } deriving (Show, Eq) -type Operator = Text -data FValue = VText Text | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq) +data Operator = Equals | Gte | Gt | Lte | Lt | Neq | Like | ILike | Is | IsNot | + TSearch | Contains | Contained | In | NotIn deriving (Eq, Enum) + +instance Show Operator where + show op = case op of + Equals -> "eq" + Gte -> "gte" + Gt -> "gt" + Lte -> "lte" + Lt -> "lt" + Neq -> "neq" + Like -> "like" + ILike -> "ilike" + In -> "in" + NotIn -> "notin" + IsNot -> "isnot" + Is -> "is" + TSearch -> "@@" + Contains -> "@>" + Contained -> "<@" + +instance Read Operator where + readsPrec _ op = case op of + "eq" -> [(Equals, "")] + "gte" -> [(Gte, "")] + "gt" -> [(Gt, "")] + "lte" -> [(Lte, "")] + "lt" -> [(Lt, "")] + "neq" -> [(Neq, "")] + "like" -> [(Like, "")] + "ilike" -> [(ILike, "")] + "in" -> [(In, "")] + "notin" -> [(NotIn, "")] + "isnot" -> [(IsNot, "")] + "is" -> [(Is, "")] + "@@" -> [(TSearch, "")] + "@>" -> [(Contains, "")] + "<@" -> [(Contained, "")] + _ -> [] + +opToSqlFragment :: Operator -> SqlFragment +opToSqlFragment op = case op of + Equals -> "=" + Gte -> ">=" + Gt -> ">" + Lte -> "<=" + Lt -> "<" + Neq -> "<>" + Like -> "LIKE" + ILike -> "ILIKE" + In -> "IN" + NotIn -> "NOT IN" + IsNot -> "IS NOT" + Is -> "IS" + TSearch -> "@@" + Contains -> "@>" + Contained -> "<@" + +data Operation = Operation{ hasNot::Bool, expr::(Operator, Operand) } deriving (Eq, Show) +data Operand = VText Text | VTextL [Text] | VForeignKey QualifiedIdentifier ForeignKey deriving (Show, Eq) type FieldName = Text type JsonPath = [Text] type Field = (FieldName, Maybe JsonPath) @@ -152,7 +209,7 @@ data ReadQuery = Select { select::[SelectItem], from::[TableName], flt_::[Filter data MutateQuery = Insert { in_::TableName, qPayload::PayloadJSON, returning::[FieldName] } | Delete { in_::TableName, where_::[Filter], returning::[FieldName] } | Update { in_::TableName, qPayload::PayloadJSON, where_::[Filter], returning::[FieldName] } deriving (Show, Eq) -data Filter = Filter {field::Field, operator::Operator, value::FValue} deriving (Show, Eq) +data Filter = Filter { field::Field, operation::Operation } deriving (Show, Eq) type ReadNode = (ReadQuery, (NodeName, Maybe Relation, Maybe Alias)) type ReadRequest = Tree ReadNode type MutateRequest = MutateQuery @@ -194,7 +251,6 @@ instance Eq Table where instance Eq Column where Column{colTable=t1,colName=n1} == Column{colTable=t2,colName=n2} = t1 == t2 && n1 == n2 - _ == _ = False -- | Convert from ContentType to a full HTTP Header toHeader :: ContentType -> Header diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs index 777697ca4..b6ba3a3f2 100644 --- a/test/Feature/QuerySpec.hs +++ b/test/Feature/QuerySpec.hs @@ -656,7 +656,7 @@ spec = do { matchHeaders = [matchContentTypeJson] } it "fails if an operator is not given" $ - get "/ghostBusters?id=0" `shouldRespondWith` [json| {"details":"unexpected \"0\" expecting \"not.\" or operator (eq, gt, ...)","message":"\"failed to parse filter (0)\" (line 1, column 1)"} |] + get "/ghostBusters?id=0" `shouldRespondWith` [json| {"details":"unexpected \"0\" expecting \"not\" or operator (eq, gt, ...)","message":"\"failed to parse filter (0)\" (line 1, column 1)"} |] { matchStatus = 400 , matchHeaders = [matchContentTypeJson] } @@ -737,3 +737,70 @@ spec = do { matchStatus = 200 , matchHeaders = [] } + + describe "values with quotes in IN and NOTIN operators" $ do + it "succeeds when only quoted values are present" $ do + get "/w_or_wo_comma_names?name=in.\"Hebdon, John\"" `shouldRespondWith` + [json| [{"name":"Hebdon, John"}] |] + { matchHeaders = [matchContentTypeJson] } + get "/w_or_wo_comma_names?name=in.\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\"" `shouldRespondWith` + [json| [{"name":"Hebdon, John"},{"name":"Williams, Mary"},{"name":"Smith, Joseph"}] |] + { matchHeaders = [matchContentTypeJson] } + get "/w_or_wo_comma_names?name=notin.\"Hebdon, John\",\"Williams, Mary\",\"Smith, Joseph\"" `shouldRespondWith` + [json| [{"name":"David White"},{"name":"Larry Thompson"}] |] + { 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"}] |] + { matchHeaders = [matchContentTypeJson] } + + it "succeeds w/ and w/o quoted values" $ do + get "/w_or_wo_comma_names?name=in.David White,\"Hebdon, John\"" `shouldRespondWith` + [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"}] |] + { matchHeaders = [matchContentTypeJson] } + get "/w_or_wo_comma_names?name=notin.\"Hebdon, John\",David White,\"Williams, Mary\",Larry Thompson" `shouldRespondWith` + [json| [{"name":"Smith, Joseph"}] |] + { 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] } + + describe "IN empty set" $ do + context "returns an empty result set when no value is present" $ do + it "works for integer" $ + get "/items_with_different_col_types?int_data=in." `shouldRespondWith` + [json| [] |] { matchHeaders = [matchContentTypeJson] } + it "works for text" $ + get "/items_with_different_col_types?text_data=in." `shouldRespondWith` + [json| [] |] { matchHeaders = [matchContentTypeJson] } + it "works for bool" $ + get "/items_with_different_col_types?bool_data=in." `shouldRespondWith` + [json| [] |] { matchHeaders = [matchContentTypeJson] } + it "works for bytea" $ + get "/items_with_different_col_types?bin_data=in." `shouldRespondWith` + [json| [] |] { matchHeaders = [matchContentTypeJson] } + it "works for char" $ + get "/items_with_different_col_types?char_data=in." `shouldRespondWith` + [json| [] |] { matchHeaders = [matchContentTypeJson] } + it "works for date" $ + get "/items_with_different_col_types?date_data=in." `shouldRespondWith` + [json| [] |] { matchHeaders = [matchContentTypeJson] } + it "works for real" $ + get "/items_with_different_col_types?real_data=in." `shouldRespondWith` + [json| [] |] { matchHeaders = [matchContentTypeJson] } + it "works for time" $ + get "/items_with_different_col_types?time_data=in." `shouldRespondWith` + [json| [] |] { matchHeaders = [matchContentTypeJson] } + + it "returns an empty result ignoring spaces" $ + get "/items_with_different_col_types?int_data=in. " `shouldRespondWith` 400 + + it "only returns an empty result set if the in value is empty" $ + get "/items_with_different_col_types?int_data=in. ,3,4" `shouldRespondWith` 400 diff --git a/test/fixtures/data.sql b/test/fixtures/data.sql index af5ed66bc..5eb61ed4e 100644 --- a/test/fixtures/data.sql +++ b/test/fixtures/data.sql @@ -287,6 +287,13 @@ TRUNCATE TABLE images CASCADE; INSERT INTO images(name, img) VALUES ('A.png', decode('iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEUAAAD/AAAb/40iAAAAP0lEQVQI12NgwAbYG2AE/wEYwQMiZB4ACQkQYZEAIgqAhAGIKLCAEQ8kgMT/P1CCEUwc4IMSzA3sUIIdCHECAGSQEkeOTUyCAAAAAElFTkSuQmCC', 'base64')); INSERT INTO images(name, img) VALUES ('B.png', decode('iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeAQMAAAAB/jzhAAAABlBMVEX///8AAP94wDzzAAAAL0lEQVQIW2NgwAb+HwARH0DEDyDxwAZEyGAhLODqHmBRzAcn5GAS///A1IF14AAA5/Adbiiz/0gAAAAASUVORK5CYII=', 'base64')); +TRUNCATE TABLE w_or_wo_comma_names CASCADE; +INSERT INTO w_or_wo_comma_names VALUES ('Hebdon, John'); +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'); + -- -- PostgreSQL database dump complete -- diff --git a/test/fixtures/privileges.sql b/test/fixtures/privileges.sql index 309d6d006..de0342034 100644 --- a/test/fixtures/privileges.sql +++ b/test/fixtures/privileges.sql @@ -51,6 +51,8 @@ GRANT ALL ON TABLE , orders_view , images , images_base64 + , w_or_wo_comma_names + , items_with_different_col_types TO postgrest_test_anonymous; GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous; diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql index 5730e0cf6..705fd6207 100755 --- a/test/fixtures/schema.sql +++ b/test/fixtures/schema.sql @@ -1161,6 +1161,18 @@ create function test.get_guc_value(name text) returns text as $$ select nullif(current_setting(name), '')::text; $$ language sql; +create table w_or_wo_comma_names ( name text ); + +create table items_with_different_col_types ( + int_data integer, + text_data text, + bool_data bool, + bin_data bytea, + char_data character varying, + date_data date, + real_data real, + time_data time +); -- -- PostgreSQL database dump complete