feat: allow not_null value for the is operator

This commit is contained in:
M. Taimoor Zaeem
2024-12-19 10:19:41 -05:00
committed by Steve Chavez
parent a1769d17be
commit a9d74eba2a
7 changed files with 51 additions and 31 deletions
+1
View File
@@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #3560, Log resolved host in "Listening on ..." messages - @develop7 - #3560, Log resolved host in "Listening on ..." messages - @develop7
- #3727, Log maximum pool size - @steve-chavez - #3727, Log maximum pool size - @steve-chavez
- #1536, Add string comparison feature for jwt-role-claim-key - @taimoorzaeem - #1536, Add string comparison feature for jwt-role-claim-key - @taimoorzaeem
- #3747, Allow `not_null` value for the `is` operator - @taimoorzaeem
### Fixed ### Fixed
+1 -1
View File
@@ -72,7 +72,7 @@ imatch :code:`~*` ~* operator, see :ref:`pattern_matching`
in :code:`IN` one of a list of values, e.g. :code:`?a=in.(1,2,3)` in :code:`IN` one of a list of values, e.g. :code:`?a=in.(1,2,3)`
also supports commas in quoted strings like also supports commas in quoted strings like
:code:`?a=in.("hi,there","yes,you")` :code:`?a=in.("hi,there","yes,you")`
is :code:`IS` checking for exact equality (null,true,false,unknown) is :code:`IS` checking for exact equality (null,not_null,true,false,unknown)
isdistinct :code:`IS DISTINCT FROM` not equal, treating :code:`NULL` as a comparable value isdistinct :code:`IS DISTINCT FROM` not equal, treating :code:`NULL` as a comparable value
fts :code:`@@` :ref:`fts` using to_tsquery fts :code:`@@` :ref:`fts` using to_tsquery
plfts :code:`@@` :ref:`fts` using plainto_tsquery plfts :code:`@@` :ref:`fts` using plainto_tsquery
+9 -9
View File
@@ -46,7 +46,7 @@ import PostgREST.SchemaCache.Identifiers (FieldName)
import PostgREST.ApiRequest.Types (AggregateFunction (..), import PostgREST.ApiRequest.Types (AggregateFunction (..),
EmbedParam (..), EmbedPath, Field, EmbedParam (..), EmbedPath, Field,
Filter (..), FtsOperator (..), Filter (..), FtsOperator (..),
Hint, JoinType (..), Hint, IsVal (..), JoinType (..),
JsonOperand (..), JsonOperand (..),
JsonOperation (..), JsonPath, JsonOperation (..), JsonPath,
ListVal, LogicOperator (..), ListVal, LogicOperator (..),
@@ -56,8 +56,7 @@ import PostgREST.ApiRequest.Types (AggregateFunction (..),
OrderNulls (..), OrderTerm (..), OrderNulls (..), OrderTerm (..),
QPError (..), QuantOperator (..), QPError (..), QuantOperator (..),
SelectItem (..), SelectItem (..),
SimpleOperator (..), SingleVal, SimpleOperator (..), SingleVal)
TrileanVal (..))
import Protolude hiding (Sum, try) import Protolude hiding (Sum, try)
@@ -640,7 +639,7 @@ pOpExpr pSVal = do
pOperation = pIn <|> pIs <|> pIsDist <|> try pFts <|> try pSimpleOp <|> try pQuantOp <?> "operator (eq, gt, ...)" pOperation = pIn <|> pIs <|> pIsDist <|> try pFts <|> try pSimpleOp <|> try pQuantOp <?> "operator (eq, gt, ...)"
pIn = In <$> (try (string "in" *> pDelimiter) *> pListVal) pIn = In <$> (try (string "in" *> pDelimiter) *> pListVal)
pIs = Is <$> (try (string "is" *> pDelimiter) *> pTriVal) pIs = Is <$> (try (string "is" *> pDelimiter) *> pIsVal)
pIsDist = IsDistinctFrom <$> (try (string "isdistinct" *> pDelimiter) *> pSVal) pIsDist = IsDistinctFrom <$> (try (string "isdistinct" *> pDelimiter) *> pSVal)
@@ -653,11 +652,12 @@ pOpExpr pSVal = do
quant <- optionMaybe $ try (between (char '(') (char ')') (try (string "any" $> QuantAny) <|> string "all" $> QuantAll)) quant <- optionMaybe $ try (between (char '(') (char ')') (try (string "any" $> QuantAny) <|> string "all" $> QuantAll))
pDelimiter *> (OpQuant op quant <$> pSVal) pDelimiter *> (OpQuant op quant <$> pSVal)
pTriVal = try (ciString "null" $> TriNull) pIsVal = try (ciString "null" $> IsNull)
<|> try (ciString "unknown" $> TriUnknown) <|> try (ciString "not_null" $> IsNotNull)
<|> try (ciString "true" $> TriTrue) <|> try (ciString "true" $> IsTriTrue)
<|> try (ciString "false" $> TriFalse) <|> try (ciString "false" $> IsTriFalse)
<?> "null or trilean value (unknown, true, false)" <|> try (ciString "unknown" $> IsTriUnknown)
<?> "isVal: (null, not_null, true, false, unknown)"
pFts = do pFts = do
op <- try (string "fts" $> FilterFts) op <- try (string "fts" $> FilterFts)
+9 -8
View File
@@ -28,7 +28,7 @@ module PostgREST.ApiRequest.Types
, RaiseError(..) , RaiseError(..)
, RangeError(..) , RangeError(..)
, SingleVal , SingleVal
, TrileanVal(..) , IsVal(..)
, SimpleOperator(..) , SimpleOperator(..)
, QuantOperator(..) , QuantOperator(..)
, FtsOperator(..) , FtsOperator(..)
@@ -218,7 +218,7 @@ data Operation
= Op SimpleOperator SingleVal = Op SimpleOperator SingleVal
| OpQuant QuantOperator (Maybe OpQuantifier) SingleVal | OpQuant QuantOperator (Maybe OpQuantifier) SingleVal
| In ListVal | In ListVal
| Is TrileanVal | Is IsVal
| IsDistinctFrom SingleVal | IsDistinctFrom SingleVal
| Fts FtsOperator (Maybe Language) SingleVal | Fts FtsOperator (Maybe Language) SingleVal
deriving (Eq, Show) deriving (Eq, Show)
@@ -231,12 +231,13 @@ type SingleVal = Text
-- | Represents a list value in a filter, e.g. id=in.(val1,val2,val3) -- | Represents a list value in a filter, e.g. id=in.(val1,val2,val3)
type ListVal = [Text] type ListVal = [Text]
-- | Three-valued logic values data IsVal
data TrileanVal = IsNull
= TriTrue | IsNotNull
| TriFalse -- Trilean values
| TriNull | IsTriTrue
| TriUnknown | IsTriFalse
| IsTriUnknown
deriving (Eq, Show) deriving (Eq, Show)
-- Operators that are quantifiable, i.e. they can be used with the any/all modifiers -- Operators that are quantifiable, i.e. they can be used with the any/all modifiers
+4 -4
View File
@@ -795,8 +795,8 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
-- --
-- Setup: -- Setup:
-- --
-- >>> let nullOp = OpExpr True (Is TriNull) -- >>> let nullOp = OpExpr True (Is IsNull)
-- >>> let nonNullOp = OpExpr False (Is TriNull) -- >>> let nonNullOp = OpExpr False (Is IsNull)
-- >>> let notEqOp = OpExpr True (Op OpNotEqual "val") -- >>> let notEqOp = OpExpr True (Op OpNotEqual "val")
-- >>> :{ -- >>> :{
-- -- this represents the `projects(*)` part on `/clients?select=*,projects(*)` -- -- this represents the `projects(*)` part on `/clients?select=*,projects(*)`
@@ -847,7 +847,7 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
-- Don't do anything to the filter if there's no embedding (a subtree) on projects. Assume it's a normal filter. -- Don't do anything to the filter if there's no embedding (a subtree) on projects. Assume it's a normal filter.
-- --
-- >>> ReadPlan.where_ . rootLabel <$> addNullEmbedFilters (readPlanTree nullOp []) -- >>> ReadPlan.where_ . rootLabel <$> addNullEmbedFilters (readPlanTree nullOp [])
-- Right [CoercibleStmnt (CoercibleFilter {field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson = False, cfIRType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False}, opExpr = OpExpr True (Is TriNull)})] -- Right [CoercibleStmnt (CoercibleFilter {field = CoercibleField {cfName = "projects", cfJsonPath = [], cfToJson = False, cfIRType = "", cfTransform = Nothing, cfDefault = Nothing, cfFullRow = False}, opExpr = OpExpr True (Is IsNull)})]
-- --
-- If there's an embedding on projects, then change the filter to use the internal aggregate name (`clients_projects_1`) so the filter can succeed later. -- If there's an embedding on projects, then change the filter to use the internal aggregate name (`clients_projects_1`) so the filter can succeed later.
-- --
@@ -869,7 +869,7 @@ addNullEmbedFilters (Node rp@ReadPlan{where_=curLogic} forest) = do
flt@(CoercibleStmnt (CoercibleFilter (CoercibleField fld [] _ _ _ _ _) opExpr)) -> flt@(CoercibleStmnt (CoercibleFilter (CoercibleField fld [] _ _ _ _ _) opExpr)) ->
let foundRP = find (\ReadPlan{relName, relAlias} -> fld == fromMaybe relName relAlias) rPlans in let foundRP = find (\ReadPlan{relName, relAlias} -> fld == fromMaybe relName relAlias) rPlans in
case (foundRP, opExpr) of case (foundRP, opExpr) of
(Just ReadPlan{relAggAlias}, OpExpr b (Is TriNull)) -> Right $ CoercibleStmnt $ CoercibleFilterNullEmbed b relAggAlias (Just ReadPlan{relAggAlias}, OpExpr b (Is IsNull)) -> Right $ CoercibleStmnt $ CoercibleFilterNullEmbed b relAggAlias
_ -> Right flt _ -> Right flt
flt@(CoercibleStmnt _) -> flt@(CoercibleStmnt _) ->
Right flt Right flt
+10 -8
View File
@@ -59,6 +59,7 @@ import NeatInterpolation (trimming)
import PostgREST.ApiRequest.Types (AggregateFunction (..), import PostgREST.ApiRequest.Types (AggregateFunction (..),
Alias, Cast, Alias, Cast,
FtsOperator (..), FtsOperator (..),
IsVal (..),
JsonOperand (..), JsonOperand (..),
JsonOperation (..), JsonOperation (..),
JsonPath, JsonPath,
@@ -69,8 +70,7 @@ import PostgREST.ApiRequest.Types (AggregateFunction (..),
OrderDirection (..), OrderDirection (..),
OrderNulls (..), OrderNulls (..),
QuantOperator (..), QuantOperator (..),
SimpleOperator (..), SimpleOperator (..))
TrileanVal (..))
import PostgREST.MediaType (MTVndPlanFormat (..), import PostgREST.MediaType (MTVndPlanFormat (..),
MTVndPlanOption (..)) MTVndPlanOption (..))
import PostgREST.Plan.ReadPlan (JoinCondition (..)) import PostgREST.Plan.ReadPlan (JoinCondition (..))
@@ -380,13 +380,15 @@ pgFmtFilter table (CoercibleFilter fld (OpExpr hasNot oper)) = notOp <> " " <> p
-- IS cannot be prepared. `PREPARE boolplan AS SELECT * FROM projects where id IS $1` will give a syntax error. -- 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;` -- 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. -- However that would not accept the TRUE/FALSE/NULL/"NOT 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 -- This is why `IS` operands are whitelisted at the Parsers.hs level
Is triVal -> " IS " <> case triVal of Is isVal -> " IS " <>
TriTrue -> "TRUE" case isVal of
TriFalse -> "FALSE" IsNull -> "NULL"
TriNull -> "NULL" IsNotNull -> "NOT NULL"
TriUnknown -> "UNKNOWN" IsTriTrue -> "TRUE"
IsTriFalse -> "FALSE"
IsTriUnknown -> "UNKNOWN"
IsDistinctFrom val -> " IS DISTINCT FROM " <> unknownLiteral val IsDistinctFrom val -> " IS DISTINCT FROM " <> unknownLiteral val
+17 -1
View File
@@ -63,11 +63,21 @@ spec = do
[json| [{"a":"1","b":"0"},{"a":"2","b":"0"}] |] [json| [{"a":"1","b":"0"},{"a":"2","b":"0"}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "matches not_null using is operator" $
get "/no_pk?a=is.not_null" `shouldRespondWith`
[json| [{"a":"1","b":"0"},{"a":"2","b":"0"}] |]
{ matchHeaders = [matchContentTypeJson] }
it "matches nulls in varchar and numeric fields alike" $ do it "matches nulls in varchar and numeric fields alike" $ do
get "/no_pk?a=is.null" `shouldRespondWith` get "/no_pk?a=is.null" `shouldRespondWith`
[json| [{"a": null, "b": null}] |] [json| [{"a": null, "b": null}] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "not.is.not_null is equivalent to is.null" $ do
get "/no_pk?a=not.is.not_null" `shouldRespondWith`
[json| [{"a": null, "b": null}] |]
{ matchHeaders = [matchContentTypeJson] }
get "/nullable_integer?a=is.null" `shouldRespondWith` [json|[{"a":null}]|] get "/nullable_integer?a=is.null" `shouldRespondWith` [json|[{"a":null}]|]
it "matches with trilean values" $ do it "matches with trilean values" $ do
@@ -83,11 +93,17 @@ spec = do
[json| [{"id": 3, "name": "wash the dishes", "done": null }] |] [json| [{"id": 3, "name": "wash the dishes", "done": null }] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
it "matches with trilean values in upper or mixed case" $ do it "matches with null and not_null values in upper or mixed case" $ do
get "/chores?done=is.NULL" `shouldRespondWith` get "/chores?done=is.NULL" `shouldRespondWith`
[json| [{"id": 3, "name": "wash the dishes", "done": null }] |] [json| [{"id": 3, "name": "wash the dishes", "done": null }] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }
get "/chores?done=is.NoT_NuLl" `shouldRespondWith`
[json| [{"id": 1, "name": "take out the garbage", "done": true }
,{"id": 2, "name": "do the laundry", "done": false }] |]
{ matchHeaders = [matchContentTypeJson] }
it "matches with trilean values in upper or mixed case" $ do
get "/chores?done=is.TRUE" `shouldRespondWith` get "/chores?done=is.TRUE" `shouldRespondWith`
[json| [{"id": 1, "name": "take out the garbage", "done": true }] |] [json| [{"id": 1, "name": "take out the garbage", "done": true }] |]
{ matchHeaders = [matchContentTypeJson] } { matchHeaders = [matchContentTypeJson] }