Skip to content

Commit 319b515

Browse files
authored
Merge pull request #2448 from digitallyinduced/fix-filterwhere-is-null-parameter-slot
Fix filterWhere with Nothing generating IS $N instead of IS NULL
2 parents 175811c + 1077dcf commit 319b515

File tree

2 files changed

+42
-10
lines changed

2 files changed

+42
-10
lines changed

ihp/IHP/QueryBuilder/Filter.hs

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,15 @@ paramValue :: DefaultParamEncoder a => a -> ConditionValue
5656
paramValue value = Param (contramap (const value) (Encoders.param defaultParam))
5757
{-# INLINE paramValue #-}
5858

59+
-- | Build the appropriate 'ConditionValue' based on the operator.
60+
-- For 'IsOp'/'IsNotOp', returns @Literal "NULL"@ (no parameter consumed).
61+
-- For all other operators, returns @Param encoder@.
62+
condValueForOp :: DefaultParamEncoder a => FilterOperator -> a -> ConditionValue
63+
condValueForOp IsOp _ = Literal "NULL"
64+
condValueForOp IsNotOp _ = Literal "NULL"
65+
condValueForOp _ value = paramValue value
66+
{-# INLINE condValueForOp #-}
67+
5968
-- | Adds a simple @WHERE x = y@ condition to the query.
6069
--
6170
-- __Example:__ Only show projects where @active@ is @True@.
@@ -93,7 +102,7 @@ paramValue value = Param (contramap (const value) (Encoders.param defaultParam))
93102
filterWhere :: forall name table model value queryBuilderProvider joinRegister. (KnownSymbol table, KnownSymbol name, DefaultParamEncoder value, HasField name model value, EqOrIsOperator value, model ~ GetModelByTableName table, HasQueryBuilder queryBuilderProvider joinRegister, Table model) => (Proxy name, value) -> queryBuilderProvider table -> queryBuilderProvider table
94103
filterWhere (name, value) queryBuilderProvider = injectQueryBuilder $ addCondition condition (getQueryBuilder queryBuilderProvider)
95104
where
96-
condition = ColumnCondition columnName (toEqOrIsOperator value) (paramValue value) Nothing Nothing
105+
condition = let op = toEqOrIsOperator value in ColumnCondition columnName op (condValueForOp op value) Nothing Nothing
97106
columnName = qualifiedColumnName (tableName @model) (symbolToText @name)
98107
{-# INLINE filterWhere #-}
99108

@@ -110,7 +119,7 @@ filterWhere (name, value) queryBuilderProvider = injectQueryBuilder $ addConditi
110119
filterWhereJoinedTable :: forall model name table value queryBuilderProvider joinRegister table'. (KnownSymbol table, KnownSymbol name, DefaultParamEncoder value, HasField name model value, EqOrIsOperator value, table ~ GetTableName model, HasQueryBuilder queryBuilderProvider joinRegister, IsJoined model joinRegister, Table model) => (Proxy name, value) -> queryBuilderProvider table' -> queryBuilderProvider table'
111120
filterWhereJoinedTable (name, value) queryBuilderProvider = injectQueryBuilder $ addCondition condition (getQueryBuilder queryBuilderProvider)
112121
where
113-
condition = ColumnCondition columnName (toEqOrIsOperator value) (paramValue value) Nothing Nothing
122+
condition = let op = toEqOrIsOperator value in ColumnCondition columnName op (condValueForOp op value) Nothing Nothing
114123
columnName = qualifiedColumnName (tableName @model) (symbolToText @name)
115124
{-# INLINE filterWhereJoinedTable #-}
116125

@@ -127,7 +136,7 @@ filterWhereJoinedTable (name, value) queryBuilderProvider = injectQueryBuilder $
127136
filterWhereCaseInsensitiveJoinedTable :: forall model name table value queryBuilderProvider joinRegister table'. (KnownSymbol table, KnownSymbol name, DefaultParamEncoder value, HasField name model value, EqOrIsOperator value, table ~ GetTableName model, HasQueryBuilder queryBuilderProvider joinRegister, IsJoined model joinRegister, Table model) => (Proxy name, value) -> queryBuilderProvider table' -> queryBuilderProvider table'
128137
filterWhereCaseInsensitiveJoinedTable (name, value) queryBuilderProvider = injectQueryBuilder $ addCondition condition (getQueryBuilder queryBuilderProvider)
129138
where
130-
condition = ColumnCondition columnName (toEqOrIsOperator value) (paramValue value) (Just "LOWER") (Just "LOWER")
139+
condition = let op = toEqOrIsOperator value in ColumnCondition columnName op (condValueForOp op value) (Just "LOWER") (Just "LOWER")
131140
columnName = qualifiedColumnName (tableName @model) (symbolToText @name)
132141
{-# INLINE filterWhereCaseInsensitiveJoinedTable #-}
133142

@@ -143,7 +152,7 @@ filterWhereCaseInsensitiveJoinedTable (name, value) queryBuilderProvider = injec
143152
filterWhereNot :: forall name table model value. (KnownSymbol table, KnownSymbol name, DefaultParamEncoder value, HasField name model value, EqOrIsOperator value, model ~ GetModelByTableName table, Table model) => (Proxy name, value) -> QueryBuilder table -> QueryBuilder table
144153
filterWhereNot (name, value) queryBuilder = addCondition condition queryBuilder
145154
where
146-
condition = ColumnCondition columnName (negateFilterOperator (toEqOrIsOperator value)) (paramValue value) Nothing Nothing
155+
condition = let op = negateFilterOperator (toEqOrIsOperator value) in ColumnCondition columnName op (condValueForOp op value) Nothing Nothing
147156
columnName = qualifiedColumnName (tableName @model) (symbolToText @name)
148157
{-# INLINE filterWhereNot #-}
149158

@@ -160,7 +169,7 @@ filterWhereNot (name, value) queryBuilder = addCondition condition queryBuilder
160169
filterWhereNotJoinedTable :: forall model name table value queryBuilderProvider joinRegister table'. (KnownSymbol table, KnownSymbol name, DefaultParamEncoder value, HasField name model value, EqOrIsOperator value, table ~ GetTableName model, HasQueryBuilder queryBuilderProvider joinRegister, IsJoined model joinRegister, Table model) => (Proxy name, value) -> queryBuilderProvider table' -> queryBuilderProvider table'
161170
filterWhereNotJoinedTable (name, value) queryBuilderProvider = injectQueryBuilder $ addCondition condition (getQueryBuilder queryBuilderProvider)
162171
where
163-
condition = ColumnCondition columnName (negateFilterOperator (toEqOrIsOperator value)) (paramValue value) Nothing Nothing
172+
condition = let op = negateFilterOperator (toEqOrIsOperator value) in ColumnCondition columnName op (condValueForOp op value) Nothing Nothing
164173
columnName = qualifiedColumnName (tableName @model) (symbolToText @name)
165174
{-# INLINE filterWhereNotJoinedTable #-}
166175
-- | Adds a @WHERE x IN (y)@ condition to the query.
@@ -180,7 +189,7 @@ filterWhereIn (name, value) queryBuilderProvider =
180189
Nothing -> injectQueryBuilder $ addCondition inCondition qb -- All values non null
181190
Just nullValue ->
182191
let
183-
isNullCondition = ColumnCondition columnName IsOp (paramValue nullValue) Nothing Nothing
192+
isNullCondition = ColumnCondition columnName IsOp (Literal "NULL") Nothing Nothing
184193
in
185194
case head nonNullValues of
186195
Just nonNullValue -> -- Some non null values, some null values
@@ -213,7 +222,7 @@ filterWhereInCaseInsensitive (name, values) queryBuilderProvider =
213222
Nothing -> injectQueryBuilder $ addCondition inCondition qb
214223
Just nullValue ->
215224
let
216-
isNullCondition = ColumnCondition columnName IsOp (paramValue nullValue) Nothing Nothing
225+
isNullCondition = ColumnCondition columnName IsOp (Literal "NULL") Nothing Nothing
217226
in
218227
case head nonNullValues of
219228
Just _ ->
@@ -268,8 +277,8 @@ filterWhereNotIn (name, value) queryBuilderProvider =
268277
Nothing -> injectQueryBuilder $ addCondition notInCondition qb -- All values non null
269278
Just nullValue ->
270279
case head nonNullValues of
271-
Just nonNullValue -> injectQueryBuilder $ addCondition (ColumnCondition columnName IsNotOp (paramValue nullValue) Nothing Nothing) (addCondition notInCondition qb) -- Some non null values, some null values
272-
Nothing -> injectQueryBuilder $ addCondition (ColumnCondition columnName IsNotOp (paramValue nullValue) Nothing Nothing) qb -- All values null
280+
Just nonNullValue -> injectQueryBuilder $ addCondition (ColumnCondition columnName IsNotOp (Literal "NULL") Nothing Nothing) (addCondition notInCondition qb) -- Some non null values, some null values
281+
Nothing -> injectQueryBuilder $ addCondition (ColumnCondition columnName IsNotOp (Literal "NULL") Nothing Nothing) qb -- All values null
273282
where
274283
-- Only NOT NULL values can be compares inside the IN expression, NULL values have to be compares using a manual appended IS expression
275284
-- https://github.com/digitallyinduced/ihp/issues/906
@@ -607,7 +616,7 @@ filterWhereSql (name, sqlCondition) queryBuilderProvider = injectQueryBuilder $
607616
filterWhereCaseInsensitive :: forall name table model value queryBuilderProvider joinRegister. (KnownSymbol table, KnownSymbol name, DefaultParamEncoder value, HasField name model value, EqOrIsOperator value, model ~ GetModelByTableName table, HasQueryBuilder queryBuilderProvider joinRegister, Table model) => (Proxy name, value) -> queryBuilderProvider table -> queryBuilderProvider table
608617
filterWhereCaseInsensitive (name, value) queryBuilderProvider = injectQueryBuilder $ addCondition condition (getQueryBuilder queryBuilderProvider)
609618
where
610-
condition = ColumnCondition columnName (toEqOrIsOperator value) (paramValue value) (Just "LOWER") (Just "LOWER")
619+
condition = let op = toEqOrIsOperator value in ColumnCondition columnName op (condValueForOp op value) (Just "LOWER") (Just "LOWER")
611620
columnName = qualifiedColumnName (tableName @model) (symbolToText @name)
612621
{-# INLINE filterWhereCaseInsensitive #-}
613622

ihp/Test/Test/QueryBuilderSpec.hs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,13 @@ tests = do
169169

170170
(toSQL theQuery) `shouldBe` ("SELECT " <> postColumns <> " FROM posts WHERE posts.external_url IS NOT NULL")
171171

172+
it "should not consume a parameter slot for IS NOT NULL" do
173+
let theQuery = query @Post
174+
|> filterWhereNot (#externalUrl, Nothing)
175+
|> filterWhere (#title, "Test" :: Text)
176+
177+
(toSQL theQuery) `shouldBe` ("SELECT " <> postColumns <> " FROM posts WHERE (posts.external_url IS NOT NULL) AND (posts.title = $1)")
178+
172179
describe "filterWhereIn" do
173180
it "should use = ANY for IN clause" do
174181
let theValues :: [Text] = ["first", "second"]
@@ -206,6 +213,14 @@ tests = do
206213

207214
(toSQL theQuery) `shouldBe` ("SELECT " <> postColumns <> " FROM posts WHERE posts.category_id IS NULL")
208215

216+
it "should not consume a parameter slot for IS NULL in mixed list" do
217+
let theValues :: [Maybe UUID] = ["44dcf2cf-a79d-4caf-a2ea-427838ba3574", Nothing]
218+
let theQuery = query @Post
219+
|> filterWhereIn (#categoryId, theValues)
220+
|> filterWhere (#title, "Test" :: Text)
221+
222+
(toSQL theQuery) `shouldBe` ("SELECT " <> postColumns <> " FROM posts WHERE ((posts.category_id = ANY ($1)) OR (posts.category_id IS NULL)) AND (posts.title = $2)")
223+
209224
describe "filterWhereInCaseInsensitive" do
210225
it "should produce a SQL with a WHERE LOWER() condition" do
211226
let theQuery = query @Post
@@ -250,6 +265,14 @@ tests = do
250265

251266
(toSQL theQuery) `shouldBe` ("SELECT " <> postColumns <> " FROM posts WHERE posts.category_id IS NOT NULL")
252267

268+
it "should not consume a parameter slot for IS NOT NULL in mixed list" do
269+
let theValues :: [Maybe UUID] = ["44dcf2cf-a79d-4caf-a2ea-427838ba3574", Nothing]
270+
let theQuery = query @Post
271+
|> filterWhereNotIn (#categoryId, theValues)
272+
|> filterWhere (#title, "Test" :: Text)
273+
274+
(toSQL theQuery) `shouldBe` ("SELECT " <> postColumns <> " FROM posts WHERE ((posts.category_id <> ALL ($1)) AND (posts.category_id IS NOT NULL)) AND (posts.title = $2)")
275+
253276
describe "filterWhereIn with JobStatus" do
254277
it "should use = ANY for JobStatus IN clause" do
255278
let jobColumns = "background_jobs.id, background_jobs.status"

0 commit comments

Comments
 (0)