diff --git a/CHANGELOG.md b/CHANGELOG.md index de41d1c78..d7a44082e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - #1075, Allow filtering top-level resource based on embedded resources filters - @steve-chavez, @Iced-Sun + This is enabled by adding `!inner` to the embedded resource, e.g. `/projects?select=*,clients!inner(*)&clients.id=eq.12` + This behavior can be enabled by default with the `db-embed-default-join='inner'` config option, which saves the need for specifying `!inner` on every request. In this case, you can go back to the previous behavior per request by specifying `!left` on the embedded resource, e.g `/projects?select=*,clients!left(*)&clients.id=eq.12` +- #1988, Allow specifying `unknown` for the `is` operator - @steve-chavez ### Fixed @@ -586,4 +587,4 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Make filter position match docs, e.g. `?order=col.asc` rather - than `?order=asc.col`. \ No newline at end of file + than `?order=asc.col`. diff --git a/src/PostgREST/Query/SqlFragment.hs b/src/PostgREST/Query/SqlFragment.hs index f01744197..8007cc474 100644 --- a/src/PostgREST/Query/SqlFragment.hs +++ b/src/PostgREST/Query/SqlFragment.hs @@ -61,7 +61,8 @@ import PostgREST.Request.Types (Alias, Field, Filter (..), Operation (..), OrderDirection (..), OrderNulls (..), - OrderTerm (..), SelectItem) + OrderTerm (..), SelectItem, + TrileanVal (..)) import Protolude hiding (cast) @@ -234,9 +235,18 @@ pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper Op op val -> pgFmtFieldOp op <> " " <> case op of "like" -> unknownLiteral (T.map star val) "ilike" -> unknownLiteral (T.map star val) - "is" -> isAllowed val _ -> unknownLiteral val + -- 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/UNKNOWN keywords. See: https://stackoverflow.com/questions/6133525/proper-way-to-set-preparedstatement-parameter-to-null-under-postgres. + -- This is why `IS` operands are whitelisted at the Parsers.hs level + Is triVal -> pgFmtField table fld <> " IS " <> case triVal of + TriTrue -> "TRUE" + TriFalse -> "FALSE" + TriNull -> "NULL" + TriUnknown -> "UNKNOWN" + -- 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. @@ -252,13 +262,6 @@ pgFmtFilter table (Filter fld (OpExpr hasNot oper)) = notOp <> " " <> case oper sqlOperator o = SQL.sql $ M.lookupDefault "=" o operators notOp = if hasNot then "NOT" else mempty star c = if c == '*' then '%' else c - -- 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. - isAllowed :: Text -> SQL.Snippet - isAllowed v = SQL.sql $ maybe - (pgFmtLit v <> "::unknown") encodeUtf8 - (find ((==) . T.toLower $ v) ["null","true","false"]) pgFmtJoinCondition :: JoinCondition -> SQL.Snippet pgFmtJoinCondition (JoinCondition (qi1, col1) (qi2, col2)) = diff --git a/src/PostgREST/Request/Parsers.hs b/src/PostgREST/Request/Parsers.hs index cd78e639e..4a8db797f 100644 --- a/src/PostgREST/Request/Parsers.hs +++ b/src/PostgREST/Request/Parsers.hs @@ -195,15 +195,22 @@ pOpExpr pSVal = try ( string "not" *> pDelimiter *> (OpExpr True <$> pOperation) pOperation = Op . toS <$> foldl1 (<|>) (try . ((<* pDelimiter) . string) . toS <$> M.keys ops) <*> pSVal <|> In <$> (try (string "in" *> pDelimiter) *> pListVal) + <|> Is <$> (try (string "is" *> pDelimiter) *> pTriVal) <|> pFts "operator (eq, gt, ...)" + pTriVal = try (string "null" $> TriNull) + <|> try (string "unknown" $> TriUnknown) + <|> try (string "true" $> TriTrue) + <|> try (string "false" $> TriFalse) + "null or trilean value (unknown, true, false)" + pFts = do 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 + ops = M.filterWithKey (const . flip notElem ("in":"is":ftsOps)) operators ftsOps = M.keys ftsOperators pSingleVal :: Parser SingleVal diff --git a/src/PostgREST/Request/Types.hs b/src/PostgREST/Request/Types.hs index 939d9b198..f33ba9a14 100644 --- a/src/PostgREST/Request/Types.hs +++ b/src/PostgREST/Request/Types.hs @@ -31,6 +31,7 @@ module PostgREST.Request.Types , ReadRequest , SelectItem , SingleVal + , TrileanVal(..) , fstFieldNames ) where @@ -209,6 +210,7 @@ data OpExpr = data Operation = Op Operator SingleVal | In ListVal + | Is TrileanVal | Fts Operator (Maybe Language) SingleVal deriving (Eq) @@ -220,3 +222,11 @@ type SingleVal = Text -- | Represents a list value in a filter, e.g. id=in.(val1,val2,val3) type ListVal = [Text] + +-- | Three-valued logic values +data TrileanVal + = TriTrue + | TriFalse + | TriNull + | TriUnknown + deriving Eq diff --git a/test/Feature/QuerySpec.hs b/test/Feature/QuerySpec.hs index 52272ae02..5bfe6890f 100644 --- a/test/Feature/QuerySpec.hs +++ b/test/Feature/QuerySpec.hs @@ -73,6 +73,23 @@ spec actualPgVersion = do get "/nullable_integer?a=is.null" `shouldRespondWith` [json|[{"a":null}]|] + it "matches with trilean values" $ do + get "/chores?done=is.true" `shouldRespondWith` + [json| [{"id": 1, "name": "take out the garbage", "done": true }] |] + { matchHeaders = [matchContentTypeJson] } + + get "/chores?done=is.false" `shouldRespondWith` + [json| [{"id": 2, "name": "do the laundry", "done": false }] |] + { matchHeaders = [matchContentTypeJson] } + + get "/chores?done=is.unknown" `shouldRespondWith` + [json| [{"id": 3, "name": "wash the dishes", "done": null }] |] + { matchHeaders = [matchContentTypeJson] } + + it "fails if 'is' used and there's no null or trilean value" $ do + get "/chores?done=is.nil" `shouldRespondWith` 400 + get "/chores?done=is.ok" `shouldRespondWith` 400 + it "matches with like" $ do get "/simple_pk?k=like.*yx" `shouldRespondWith` [json|[{"k":"xyyx","extra":"u"}]|] diff --git a/test/fixtures/data.sql b/test/fixtures/data.sql index d44f9e07e..71a0efe29 100644 --- a/test/fixtures/data.sql +++ b/test/fixtures/data.sql @@ -727,3 +727,6 @@ INSERT INTO test.contact (id,name, clientid) values (1,'Wally Walton',1),(2,'Wil TRUNCATE TABLE test.clientinfo CASCADE; INSERT INTO test.clientinfo (id,clientid, other) values (1,1,'123 Main St'),(2,2,'456 South 3rd St'),(3,3,'789 Palm Tree Ln'); + +TRUNCATE TABLE test.chores CASCADE; +INSERT INTO test.chores (id, name, done) values (1, 'take out the garbage', true), (2, 'do the laundry', false), (3, 'wash the dishes', null); diff --git a/test/fixtures/privileges.sql b/test/fixtures/privileges.sql index df5311e89..b103eeae3 100644 --- a/test/fixtures/privileges.sql +++ b/test/fixtures/privileges.sql @@ -159,6 +159,7 @@ GRANT ALL ON TABLE , client , clientinfo , contact + , chores TO postgrest_test_anonymous; GRANT INSERT ON TABLE insertonly TO postgrest_test_anonymous; @@ -213,4 +214,4 @@ DO $do$BEGIN GRANT ALL ON TABLE test.car_models_car_dealers_10to20 TO postgrest_test_anonymous; GRANT ALL ON TABLE test.car_models_car_dealers_default TO postgrest_test_anonymous; END IF; -END$do$; \ No newline at end of file +END$do$; diff --git a/test/fixtures/schema.sql b/test/fixtures/schema.sql index fe60d9e92..ba1fe6a07 100644 --- a/test/fixtures/schema.sql +++ b/test/fixtures/schema.sql @@ -2417,3 +2417,9 @@ CREATE TABLE clientinfo ( , clientid int unique references client(id) , other text ); + +CREATE TABLE chores ( + id int primary key +, name text +, done bool +);