Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow not_null value for the is operator #3818

Merged
merged 1 commit into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
- #3560, Log resolved host in "Listening on ..." messages - @develop7
- #3727, Log maximum pool size - @steve-chavez
- #1536, Add string comparison feature for jwt-role-claim-key - @taimoorzaeem
- #3747, Allow `not_null` value for the `is` operator - @taimoorzaeem

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion docs/references/api/tables_views.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)`
– also supports commas in quoted strings like
: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
fts :code:`@@` :ref:`fts` using to_tsquery
plfts :code:`@@` :ref:`fts` using plainto_tsquery
Expand Down
18 changes: 9 additions & 9 deletions src/PostgREST/ApiRequest/QueryParams.hs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import PostgREST.SchemaCache.Identifiers (FieldName)
import PostgREST.ApiRequest.Types (AggregateFunction (..),
EmbedParam (..), EmbedPath, Field,
Filter (..), FtsOperator (..),
Hint, JoinType (..),
Hint, IsVal (..), JoinType (..),
JsonOperand (..),
JsonOperation (..), JsonPath,
ListVal, LogicOperator (..),
Expand All @@ -56,8 +56,7 @@ import PostgREST.ApiRequest.Types (AggregateFunction (..),
OrderNulls (..), OrderTerm (..),
QPError (..), QuantOperator (..),
SelectItem (..),
SimpleOperator (..), SingleVal,
TrileanVal (..))
SimpleOperator (..), SingleVal)

import Protolude hiding (Sum, try)

Expand Down Expand Up @@ -640,7 +639,7 @@ pOpExpr pSVal = do
pOperation = pIn <|> pIs <|> pIsDist <|> try pFts <|> try pSimpleOp <|> try pQuantOp <?> "operator (eq, gt, ...)"

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)

Expand All @@ -653,11 +652,12 @@ pOpExpr pSVal = do
quant <- optionMaybe $ try (between (char '(') (char ')') (try (string "any" $> QuantAny) <|> string "all" $> QuantAll))
pDelimiter *> (OpQuant op quant <$> pSVal)

pTriVal = try (ciString "null" $> TriNull)
<|> try (ciString "unknown" $> TriUnknown)
<|> try (ciString "true" $> TriTrue)
<|> try (ciString "false" $> TriFalse)
<?> "null or trilean value (unknown, true, false)"
pIsVal = try (ciString "null" $> IsNull)
<|> try (ciString "not_null" $> IsNotNull)
<|> try (ciString "true" $> IsTriTrue)
<|> try (ciString "false" $> IsTriFalse)
<|> try (ciString "unknown" $> IsTriUnknown)
<?> "isVal: (null, not_null, true, false, unknown)"

pFts = do
op <- try (string "fts" $> FilterFts)
Expand Down
17 changes: 9 additions & 8 deletions src/PostgREST/ApiRequest/Types.hs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ module PostgREST.ApiRequest.Types
, RaiseError(..)
, RangeError(..)
, SingleVal
, TrileanVal(..)
, IsVal(..)
, SimpleOperator(..)
, QuantOperator(..)
, FtsOperator(..)
Expand Down Expand Up @@ -218,7 +218,7 @@ data Operation
= Op SimpleOperator SingleVal
| OpQuant QuantOperator (Maybe OpQuantifier) SingleVal
| In ListVal
| Is TrileanVal
| Is IsVal
| IsDistinctFrom SingleVal
| Fts FtsOperator (Maybe Language) SingleVal
deriving (Eq, Show)
Expand All @@ -231,12 +231,13 @@ 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
data IsVal
= IsNull
| IsNotNull
-- Trilean values
| IsTriTrue
| IsTriFalse
| IsTriUnknown
deriving (Eq, Show)

-- Operators that are quantifiable, i.e. they can be used with the any/all modifiers
Expand Down
8 changes: 4 additions & 4 deletions src/PostgREST/Plan.hs
Original file line number Diff line number Diff line change
Expand Up @@ -795,8 +795,8 @@ addRelatedOrders (Node rp@ReadPlan{order,from} forest) = do
--
-- Setup:
--
-- >>> let nullOp = OpExpr True (Is TriNull)
-- >>> let nonNullOp = OpExpr False (Is TriNull)
-- >>> let nullOp = OpExpr True (Is IsNull)
-- >>> let nonNullOp = OpExpr False (Is IsNull)
-- >>> let notEqOp = OpExpr True (Op OpNotEqual "val")
-- >>> :{
-- -- this represents the `projects(*)` part on `/clients?select=*,projects(*)`
Expand Down Expand Up @@ -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.
--
-- >>> 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.
--
Expand All @@ -869,7 +869,7 @@ addNullEmbedFilters (Node rp@ReadPlan{where_=curLogic} forest) = do
flt@(CoercibleStmnt (CoercibleFilter (CoercibleField fld [] _ _ _ _ _) opExpr)) ->
let foundRP = find (\ReadPlan{relName, relAlias} -> fld == fromMaybe relName relAlias) rPlans in
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
flt@(CoercibleStmnt _) ->
Right flt
Expand Down
18 changes: 10 additions & 8 deletions src/PostgREST/Query/SqlFragment.hs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import NeatInterpolation (trimming)
import PostgREST.ApiRequest.Types (AggregateFunction (..),
Alias, Cast,
FtsOperator (..),
IsVal (..),
JsonOperand (..),
JsonOperation (..),
JsonPath,
Expand All @@ -69,8 +70,7 @@ import PostgREST.ApiRequest.Types (AggregateFunction (..),
OrderDirection (..),
OrderNulls (..),
QuantOperator (..),
SimpleOperator (..),
TrileanVal (..))
SimpleOperator (..))
import PostgREST.MediaType (MTVndPlanFormat (..),
MTVndPlanOption (..))
import PostgREST.Plan.ReadPlan (JoinCondition (..))
Expand Down Expand Up @@ -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.
-- 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
Is triVal -> " IS " <> case triVal of
TriTrue -> "TRUE"
TriFalse -> "FALSE"
TriNull -> "NULL"
TriUnknown -> "UNKNOWN"
Is isVal -> " IS " <>
case isVal of
IsNull -> "NULL"
IsNotNull -> "NOT NULL"
IsTriTrue -> "TRUE"
IsTriFalse -> "FALSE"
IsTriUnknown -> "UNKNOWN"

IsDistinctFrom val -> " IS DISTINCT FROM " <> unknownLiteral val

Expand Down
18 changes: 17 additions & 1 deletion test/spec/Feature/Query/QuerySpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,21 @@ spec = do
[json| [{"a":"1","b":"0"},{"a":"2","b":"0"}] |]
{ 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
get "/no_pk?a=is.null" `shouldRespondWith`
[json| [{"a": null, "b": null}] |]
{ 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}]|]

it "matches with trilean values" $ do
Expand All @@ -83,11 +93,17 @@ spec = do
[json| [{"id": 3, "name": "wash the dishes", "done": null }] |]
{ 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`
[json| [{"id": 3, "name": "wash the dishes", "done": null }] |]
{ 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`
[json| [{"id": 1, "name": "take out the garbage", "done": true }] |]
{ matchHeaders = [matchContentTypeJson] }
Expand Down
Loading