Skip to content

Commit

Permalink
Change phrase/plain full text search syntax
Browse files Browse the repository at this point in the history
  • Loading branch information
steve-chavez committed Nov 27, 2017
1 parent 678b855 commit 7ea1296
Show file tree
Hide file tree
Showing 9 changed files with 48 additions and 87 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 4 additions & 10 deletions src/PostgREST/ApiRequest.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
16 changes: 7 additions & 9 deletions src/PostgREST/Parsers.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions src/PostgREST/QueryBuilder.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 11 additions & 11 deletions src/PostgREST/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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", ">"),
Expand All @@ -161,7 +161,6 @@ operators = M.fromList [
("ilike", "ILIKE"),
("in", "IN"),
("is", "IS"),
("fts", "@@"),
("cs", "@>"),
("cd", "<@"),
("ov", "&&"),
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion test/Feature/AndOrParamsSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
30 changes: 6 additions & 24 deletions test/Feature/PgVersion96Spec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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] }
24 changes: 7 additions & 17 deletions test/Feature/QuerySpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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] }

Expand All @@ -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"},
Expand All @@ -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] }
Expand Down Expand Up @@ -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]
}
Expand Down
13 changes: 5 additions & 8 deletions test/Feature/RpcSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -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] }

0 comments on commit 7ea1296

Please sign in to comment.