diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ec07dd735..a8fccb2b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added -- #887, #601, Allow specifying dictionary and plain/phrase tsquery in full text search - @steve-chavez +- #887, #601, #1007, Allow specifying dictionary and plain/phrase tsquery in full text search - @steve-chavez - #328, Allow doing GET on rpc - @steve-chavez - #917, Add ability to map RAISE errorcode/message to http status - @steve-chavez - #940, Add ability to map GUC to http response headers - @steve-chavez diff --git a/src/PostgREST/ApiRequest.hs b/src/PostgREST/ApiRequest.hs index 05bae221c8..9818f0b2c0 100644 --- a/src/PostgREST/ApiRequest.hs +++ b/src/PostgREST/ApiRequest.hs @@ -40,7 +40,7 @@ import PostgREST.Types ( QualifiedIdentifier (..) , ApiRequestError(..) , toMime , operators - , FtsMode(..)) + , ftsOperators) import Data.Ranged.Ranges (Range(..), rangeIntersection, emptyRange) import qualified Data.CaseInsensitive as CI import Web.Cookie (parseCookiesText) @@ -142,16 +142,10 @@ userApiRequest schema req reqBody ActionInvoke{isReadOnly=True} -> partition (liftM2 (||) (isEmbedPath . fst) (hasOperator . snd)) flts _ -> (flts, []) flts = [ (toS k, toS $ fromJust v) | (k,v) <- qParams, isJust v, k /= "select", not (endingIn ["order", "limit", "offset", "and", "or"] k) ] - hasOperator val = foldr ((||) . flip T.isPrefixOf val) False ((<> ".") <$> (M.keys operators ++ ["not", show Plain, show Phrase])) - || hasLanguageFts val + hasOperator val = any (`T.isPrefixOf` val) $ + ((<> ".") <$> "not":M.keys operators) ++ + ((<> "(") <$> M.keys ftsOperators) isEmbedPath = T.isInfixOf "." - -- handle "?tsv=english.fts.possible" case - hasLanguageFts val = case T.splitOn "." val of - [_, "fts", _] -> True - [_, "fts"] -> True - [_, "@@", _] -> True -- TODO: '@@' deprecated - [_, "@@"] -> True - _ -> False isTargetingProc = fromMaybe False $ (== "rpc") <$> listToMaybe path payload = case decodeContentType . fromMaybe "application/json" $ lookupHeader "content-type" of diff --git a/src/PostgREST/Parsers.hs b/src/PostgREST/Parsers.hs index d27522dfc3..1b4edf25c5 100644 --- a/src/PostgREST/Parsers.hs +++ b/src/PostgREST/Parsers.hs @@ -145,16 +145,14 @@ pOpExpr pSVal pLVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOper <|> In <$> (string "in" *> pDelimiter *> pLVal) <|> pFts "operator (eq, gt, ...)" + pFts = do - mode <- option Normal $ - try (string (show Phrase) *> pDelimiter *> pure Phrase) - <|> try (string (show Plain) *> pDelimiter *> pure Plain) - - lang <- try (Just <$> manyTill (letter <|> digit <|> oneOf "_") (try (string ".fts") <|> try (string ".@@")) <* pDelimiter) -- TODO: '@@' deprecated - <|> try (string "fts" *> pDelimiter) *> pure Nothing - <|> try (string "@@" *> pDelimiter) *> pure Nothing -- TODO: '@@' deprecated - Fts mode (toS <$> lang) <$> pSVal - ops = M.filterWithKey (const . flip notElem ["in", "fts", "@@"]) operators -- TODO: '@@' deprecated + op <- foldl1 (<|>) (try . string . toS <$> ftsOps) + lang <- optionMaybe $ try (between (char '(') (char ')') (many (letter <|> digit <|> oneOf "_"))) + pDelimiter >> Fts (toS op) (toS <$> lang) <$> pSVal + + ops = M.filterWithKey (const . flip notElem ("in":ftsOps)) operators + ftsOps = M.keys ftsOperators pSingleVal :: Parser SingleVal pSingleVal = toS <$> many anyChar diff --git a/src/PostgREST/QueryBuilder.hs b/src/PostgREST/QueryBuilder.hs index ce977dcdbb..323c2bfef7 100644 --- a/src/PostgREST/QueryBuilder.hs +++ b/src/PostgREST/QueryBuilder.hs @@ -433,12 +433,12 @@ pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper Just True -> emptyValForIn Nothing -> emptyValForIn - Fts mode lang val -> - pgFmtFieldOp "fts" <> " " <> case mode of - Normal -> "to_tsquery(" - Plain -> "plainto_tsquery(" - Phrase -> "phraseto_tsquery(" - <> maybe "" (flip (<>) ", " . pgFmtLit) lang <> unknownLiteral val <> ") " + Fts op lang val -> + pgFmtFieldOp op + <> "(" + <> maybe "" ((<> ", ") . pgFmtLit) lang + <> unknownLiteral val + <> ") " Join fQi (ForeignKey Column{colTable=Table{tableName=fTableName}, colName=fColName}) -> pgFmtField fQi fld <> " = " <> pgFmtColumn (removeSourceCTESchema (qiSchema fQi) fTableName) fColName diff --git a/src/PostgREST/Types.hs b/src/PostgREST/Types.hs index 9ad8a589d4..d8f0a2b51e 100644 --- a/src/PostgREST/Types.hs +++ b/src/PostgREST/Types.hs @@ -150,7 +150,7 @@ data Proxy = Proxy { type Operator = Text operators :: M.HashMap Operator SqlFragment -operators = M.fromList [ +operators = M.union (M.fromList [ ("eq", "="), ("gte", ">="), ("gt", ">"), @@ -161,7 +161,6 @@ operators = M.fromList [ ("ilike", "ILIKE"), ("in", "IN"), ("is", "IS"), - ("fts", "@@"), ("cs", "@>"), ("cd", "<@"), ("ov", "&&"), @@ -171,21 +170,22 @@ operators = M.fromList [ ("nxl", "&>"), ("adj", "-|-"), -- TODO: these are deprecated and should be removed in v0.5.0.0 - ("@@", "@@"), ("@>", "@>"), - ("<@", "<@")] + ("<@", "<@")]) ftsOperators + +ftsOperators :: M.HashMap Operator SqlFragment +ftsOperators = M.fromList [ + ("@@", "@@ to_tsquery"), -- TODO: '@@' deprecated + ("fts", "@@ to_tsquery"), + ("plfts", "@@ plainto_tsquery"), + ("phfts", "@@ phraseto_tsquery") + ] data OpExpr = OpExpr Bool Operation deriving (Eq, Show) data Operation = Op Operator SingleVal | In ListVal | - Fts FtsMode (Maybe Language) SingleVal | + Fts Operator (Maybe Language) SingleVal | Join QualifiedIdentifier ForeignKey deriving (Eq, Show) - -data FtsMode = Normal | Plain | Phrase deriving Eq -instance Show FtsMode where - show Normal = mzero - show Plain = "plain" - show Phrase = "phrase" type Language = Text -- | Represents a single value in a filter, e.g. id=eq.singleval diff --git a/test/Feature/AndOrParamsSpec.hs b/test/Feature/AndOrParamsSpec.hs index 1e4145fba8..641786022a 100644 --- a/test/Feature/AndOrParamsSpec.hs +++ b/test/Feature/AndOrParamsSpec.hs @@ -73,7 +73,7 @@ spec = it "can handle fts" $ do get "/entities?or=(text_search_vector.fts.bar,text_search_vector.fts.baz)&select=id" `shouldRespondWith` [json|[{ "id": 1 }, { "id": 2 }]|] { matchHeaders = [matchContentTypeJson] } - get "/tsearch?or=(text_search_vector.plain.german.fts.Art%20Spass, text_search_vector.plain.french.fts.amusant%20impossible, text_search_vector.english.fts.impossible)" `shouldRespondWith` + get "/tsearch?or=(text_search_vector.plfts(german).Art%20Spass, text_search_vector.plfts(french).amusant%20impossible, text_search_vector.fts(english).impossible)" `shouldRespondWith` [json|[ {"text_search_vector": "'fun':5 'imposs':9 'kind':3" }, {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }, diff --git a/test/Feature/PgVersion96Spec.hs b/test/Feature/PgVersion96Spec.hs index 40d8f5640f..233239ea10 100644 --- a/test/Feature/PgVersion96Spec.hs +++ b/test/Feature/PgVersion96Spec.hs @@ -49,17 +49,17 @@ spec = context "Use of the phraseto_tsquery function" $ do it "finds matches" $ - get "/tsearch?text_search_vector=phrase.fts.The%20Fat%20Cats" `shouldRespondWith` + get "/tsearch?text_search_vector=phfts.The%20Fat%20Cats" `shouldRespondWith` [json| [{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |] { matchHeaders = [matchContentTypeJson] } it "finds matches with different dictionaries" $ - get "/tsearch?text_search_vector=phrase.german.fts.Art%20Spass" `shouldRespondWith` + get "/tsearch?text_search_vector=phfts(german).Art%20Spass" `shouldRespondWith` [json| [{"text_search_vector": "'art':4 'spass':5 'unmog':7" }] |] { matchHeaders = [matchContentTypeJson] } it "can be negated with not operator" $ - get "/tsearch?text_search_vector=not.phrase.english.fts.The%20Fat%20Cats" `shouldRespondWith` + get "/tsearch?text_search_vector=not.phfts(english).The%20Fat%20Cats" `shouldRespondWith` [json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}, {"text_search_vector": "'also':2 'fun':3 'possibl':8"}, @@ -68,32 +68,14 @@ spec = { matchHeaders = [matchContentTypeJson] } it "can be used with or query param" $ - get "/tsearch?or=(text_search_vector.phrase.german.fts.Art%20Spass, text_search_vector.phrase.french.fts.amusant, text_search_vector.english.fts.impossible)" `shouldRespondWith` + get "/tsearch?or=(text_search_vector.phfts(german).Art%20Spass, text_search_vector.phfts(french).amusant, text_search_vector.fts(english).impossible)" `shouldRespondWith` [json|[ {"text_search_vector": "'fun':5 'imposs':9 'kind':3" }, {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }, {"text_search_vector": "'art':4 'spass':5 'unmog':7"} ]|] { matchHeaders = [matchContentTypeJson] } - -- TODO: remove in 0.5.0 as deprecated - it "Deprecated @@ operator, pending to remove" $ do - get "/tsearch?text_search_vector=phrase.@@.The%20Fat%20Cats" `shouldRespondWith` - [json| [{"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |] - { matchHeaders = [matchContentTypeJson] } - get "/tsearch?text_search_vector=not.phrase.english.@@.The%20Fat%20Cats" `shouldRespondWith` - [json| [ - {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}, - {"text_search_vector": "'also':2 'fun':3 'possibl':8"}, - {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"}, - {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|] - { matchHeaders = [matchContentTypeJson] } - - context "GET rpc" $ - it "should work with phrase fts" $ do - get "/rpc/get_tsearch?text_search_vector=phrase.english.fts.impossible" `shouldRespondWith` - [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|] - { matchHeaders = [matchContentTypeJson] } - -- TODO: '@@' deprecated - get "/rpc/get_tsearch?text_search_vector=phrase.english.@@.impossible" `shouldRespondWith` + it "should work when used with GET RPC" $ + get "/rpc/get_tsearch?text_search_vector=phfts(english).impossible" `shouldRespondWith` [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|] { matchHeaders = [matchContentTypeJson] } diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs index 4f8a41442a..5e6872a19f 100644 --- a/test/Feature/QuerySpec.hs +++ b/test/Feature/QuerySpec.hs @@ -112,15 +112,15 @@ spec = do { matchHeaders = [matchContentTypeJson] } it "finds matches with plainto_tsquery" $ - get "/tsearch?text_search_vector=plain.fts.The%20Fat%20Rats" `shouldRespondWith` + get "/tsearch?text_search_vector=plfts.The%20Fat%20Rats" `shouldRespondWith` [json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |] { matchHeaders = [matchContentTypeJson] } it "finds matches with different dictionaries" $ do - get "/tsearch?text_search_vector=french.fts.amusant" `shouldRespondWith` + get "/tsearch?text_search_vector=fts(french).amusant" `shouldRespondWith` [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |] { matchHeaders = [matchContentTypeJson] } - get "/tsearch?text_search_vector=plain.french.fts.amusant%20impossible" `shouldRespondWith` + get "/tsearch?text_search_vector=plfts(french).amusant%20impossible" `shouldRespondWith` [json| [{"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4" }] |] { matchHeaders = [matchContentTypeJson] } @@ -130,12 +130,12 @@ spec = do {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"}, {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|] { matchHeaders = [matchContentTypeJson] } - get "/tsearch?text_search_vector=not.english.fts.impossible%7Cfat%7Cfun" `shouldRespondWith` + get "/tsearch?text_search_vector=not.fts(english).impossible%7Cfat%7Cfun" `shouldRespondWith` [json| [ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"}, {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|] { matchHeaders = [matchContentTypeJson] } - get "/tsearch?text_search_vector=not.plain.fts.The%20Fat%20Rats" `shouldRespondWith` + get "/tsearch?text_search_vector=not.plfts.The%20Fat%20Rats" `shouldRespondWith` [json| [ {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}, {"text_search_vector": "'also':2 'fun':3 'possibl':8"}, @@ -148,23 +148,13 @@ spec = do get "/tsearch?text_search_vector=@@.impossible" `shouldRespondWith` [json| [{"text_search_vector": "'fun':5 'imposs':9 'kind':3" }] |] { matchHeaders = [matchContentTypeJson] } - get "/tsearch?text_search_vector=plain.@@.The%20Fat%20Rats" `shouldRespondWith` - [json| [ {"text_search_vector": "'ate':3 'cat':2 'fat':1 'rat':4" }] |] - { matchHeaders = [matchContentTypeJson] } get "/tsearch?text_search_vector=not.@@.impossible%7Cfat%7Cfun" `shouldRespondWith` [json| [ {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"}, {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|] { matchHeaders = [matchContentTypeJson] } - get "/tsearch?text_search_vector=not.english.@@.impossible%7Cfat%7Cfun" `shouldRespondWith` - [json| [ - {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"}, - {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|] - { matchHeaders = [matchContentTypeJson] } - get "/tsearch?text_search_vector=not.plain.@@.The%20Fat%20Rats" `shouldRespondWith` + get "/tsearch?text_search_vector=not.@@(english).impossible%7Cfat%7Cfun" `shouldRespondWith` [json| [ - {"text_search_vector": "'fun':5 'imposs':9 'kind':3"}, - {"text_search_vector": "'also':2 'fun':3 'possibl':8"}, {"text_search_vector": "'amus':5 'fair':7 'impossibl':9 'peu':4"}, {"text_search_vector": "'art':4 'spass':5 'unmog':7"}]|] { matchHeaders = [matchContentTypeJson] } @@ -548,7 +538,7 @@ spec = do { matchHeaders = [matchContentTypeJson] } it "fails if an operator is not given" $ - get "/ghostBusters?id=0" `shouldRespondWith` [json| {"details":"unexpected end of input expecting operator (eq, gt, ...)","message":"\"failed to parse filter (0)\" (line 1, column 2)"} |] + 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] } diff --git a/test/Feature/RpcSpec.hs b/test/Feature/RpcSpec.hs index 3987ddb265..f7c0710ee6 100644 --- a/test/Feature/RpcSpec.hs +++ b/test/Feature/RpcSpec.hs @@ -325,22 +325,19 @@ spec = { matchHeaders = [matchContentTypeJson] } it "should work with filters that use the plain with language fts operator" $ do - get "/rpc/get_tsearch?text_search_vector=english.fts.impossible" `shouldRespondWith` + get "/rpc/get_tsearch?text_search_vector=fts(english).impossible" `shouldRespondWith` [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|] { matchHeaders = [matchContentTypeJson] } - get "/rpc/get_tsearch?text_search_vector=plain.fts.impossible" `shouldRespondWith` + get "/rpc/get_tsearch?text_search_vector=plfts.impossible" `shouldRespondWith` [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|] { matchHeaders = [matchContentTypeJson] } - get "/rpc/get_tsearch?text_search_vector=not.english.fts.fun%7Crat" `shouldRespondWith` + get "/rpc/get_tsearch?text_search_vector=not.fts(english).fun%7Crat" `shouldRespondWith` [json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|] { matchHeaders = [matchContentTypeJson] } -- TODO: '@@' deprecated - get "/rpc/get_tsearch?text_search_vector=english.@@.impossible" `shouldRespondWith` + get "/rpc/get_tsearch?text_search_vector=@@(english).impossible" `shouldRespondWith` [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|] { matchHeaders = [matchContentTypeJson] } - get "/rpc/get_tsearch?text_search_vector=plain.@@.impossible" `shouldRespondWith` - [json|[{"text_search_vector":"'fun':5 'imposs':9 'kind':3"}]|] - { matchHeaders = [matchContentTypeJson] } - get "/rpc/get_tsearch?text_search_vector=not.english.@@.fun%7Crat" `shouldRespondWith` + get "/rpc/get_tsearch?text_search_vector=not.@@(english).fun%7Crat" `shouldRespondWith` [json|[{"text_search_vector":"'amus':5 'fair':7 'impossibl':9 'peu':4"},{"text_search_vector":"'art':4 'spass':5 'unmog':7"}]|] { matchHeaders = [matchContentTypeJson] }