Skip to content

Commit bb3577a

Browse files
committed
Added tsvector based fulltext search for DataSync
This allows a simple search fulltext search to implemented like this: function ProductSearch() { const [searchQuery, setSearchQuery] = useState(''); const onChange = useCallback(event => setSearchQuery(event.target.value), [ setSearchQuery ]); const products = useQuery(query('products').whereTextSearchStartsWith('ts', searchQuery)); return <form> <input type="text" className="form-control" value={searchQuery} onChange={onChange}/> <div> Result: {products?.map(product => <div>{product.name}</div>)} </div> </form> }
1 parent 97a04b4 commit bb3577a

File tree

6 files changed

+95
-16
lines changed

6 files changed

+95
-16
lines changed

IHP/DataSync/Controller.hs

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{-# LANGUAGE UndecidableInstances #-}
22
module IHP.DataSync.Controller where
33

4-
import IHP.ControllerPrelude
4+
import IHP.ControllerPrelude hiding (OrderByClause)
55
import qualified Control.Exception as Exception
66
import qualified IHP.Log as Log
77
import qualified Data.Aeson as Aeson
@@ -354,13 +354,6 @@ changesToValue changes = object (map changeToPair changes)
354354
where
355355
changeToPair ChangeNotifications.Change { col, new } = (columnNameToFieldName col) .= new
356356
357-
queryFieldNamesToColumnNames :: SQLQuery -> SQLQuery
358-
queryFieldNamesToColumnNames sqlQuery = sqlQuery
359-
|> modify #orderByClause (map convertOrderByClause)
360-
where
361-
convertOrderByClause OrderByClause { orderByColumn, orderByDirection } = OrderByClause { orderByColumn = cs (fieldNameToColumnName (cs orderByColumn)), orderByDirection }
362-
363-
364357
runInModelContextWithTransaction :: (?state :: IORef DataSyncController, _) => ((?modelContext :: ModelContext) => IO result) -> Maybe UUID -> IO result
365358
runInModelContextWithTransaction function (Just transactionId) = do
366359
let globalModelContext = ?modelContext

IHP/DataSync/DynamicQuery.hs

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1+
{-# LANGUAGE DeriveAnyClass #-}
12
{-|
23
Module: IHP.DataSync.DynamicQuery
34
Description: The normal IHP query functionality is type-safe. This module provides type-unsafe access to the database.
45
Copyright: (c) digitally induced GmbH, 2021
56
-}
67
module IHP.DataSync.DynamicQuery where
78

8-
import IHP.ControllerPrelude
9+
import IHP.ControllerPrelude hiding (OrderByClause)
910
import Data.Aeson
1011
import qualified Data.HashMap.Strict as HashMap
1112
import qualified Database.PostgreSQL.Simple as PG
@@ -16,6 +17,8 @@ import qualified Database.PostgreSQL.Simple.Types as PG
1617
import qualified Database.PostgreSQL.Simple.Notification as PG
1718
import qualified IHP.QueryBuilder as QueryBuilder
1819
import Data.Aeson.TH
20+
import qualified GHC.Generics
21+
import qualified Control.DeepSeq as DeepSeq
1922

2023
data Field = Field { fieldName :: Text, fieldValue :: DynamicValue }
2124

@@ -45,14 +48,26 @@ data DynamicSQLQuery = DynamicSQLQuery
4548
, offset :: !(Maybe Int)
4649
} deriving (Show, Eq)
4750

51+
data OrderByClause
52+
= OrderByClause
53+
{ orderByColumn :: !ByteString
54+
, orderByDirection :: !OrderByDirection }
55+
| OrderByTSRank { tsvector :: Text, tsquery :: !Text }
56+
deriving (Show, Eq, GHC.Generics.Generic, DeepSeq.NFData)
57+
4858
-- | Represents a WHERE conditions of a 'DynamicSQLQuery'
4959
data ConditionExpression
5060
= ColumnExpression { field :: !Text }
5161
| NullExpression
5262
| InfixOperatorExpression { left :: !ConditionExpression, op :: !ConditionOperator, right :: !ConditionExpression }
5363
| LiteralExpression { value :: !DynamicValue }
64+
| CallExpression { functionCall :: !FunctionCall }
5465
deriving (Show, Eq)
5566

67+
data FunctionCall
68+
= ToTSQuery { text :: !Text } -- ^ to_tsquery('english', text)
69+
deriving (Show, Eq, GHC.Generics.Generic, DeepSeq.NFData)
70+
5671
-- | Operators available in WHERE conditions
5772
data ConditionOperator
5873
= OpEqual -- ^ a = b
@@ -65,6 +80,7 @@ data ConditionOperator
6580
| OpOr -- ^ a OR b
6681
| OpIs -- ^ a IS b
6782
| OpIsNot -- ^ a IS NOT b
83+
| OpTSMatch -- ^ tsvec_a @@ tsvec_b
6884
deriving (Show, Eq)
6985

7086
data SelectedColumns
@@ -148,6 +164,7 @@ $(deriveFromJSON defaultOptions 'SelectAll)
148164
$(deriveFromJSON defaultOptions ''ConditionOperator)
149165
$(deriveFromJSON defaultOptions ''ConditionExpression)
150166
$(deriveFromJSON defaultOptions ''DynamicValue)
167+
$(deriveFromJSON defaultOptions ''FunctionCall)
151168

152169
instance FromJSON DynamicSQLQuery where
153170
parseJSON = withObject "DynamicSQLQuery" $ \v -> DynamicSQLQuery
@@ -156,4 +173,19 @@ instance FromJSON DynamicSQLQuery where
156173
<*> v .: "whereCondition"
157174
<*> v .: "orderByClause"
158175
<*> v .:? "limit" -- Limit can be absent in older versions of ihp-datasync.js
159-
<*> v .:? "offset" -- Offset can be absent in older versions of ihp-datasync.js
176+
<*> v .:? "offset" -- Offset can be absent in older versions of ihp-datasync.js
177+
178+
179+
instance FromJSON OrderByClause where
180+
parseJSON = withObject "OrderByClause" $ \v -> do
181+
let oldFormat = OrderByClause
182+
<$> v .: "orderByColumn"
183+
<*> v .: "orderByDirection"
184+
let tagged = do
185+
tag <- v .: "tag"
186+
case tag of
187+
"OrderByClause" -> oldFormat
188+
"OrderByTSRank" -> OrderByTSRank <$> v .: "tsvector" <*> v .: "tsquery"
189+
otherwise -> error ("Invalid tag: " <> otherwise)
190+
tagged <|> oldFormat
191+

IHP/DataSync/DynamicQueryCompiler.hs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,20 @@ compileQuery DynamicSQLQuery { .. } = (sql, args)
3232
(orderBySql, orderByArgs) = case orderByClause of
3333
[] -> ("", [])
3434
orderByClauses ->
35-
( PG.Query $ cs $ " ORDER BY " <> (intercalate ", " (map (const "? ?") orderByClauses))
35+
( PG.Query $ cs $ " ORDER BY " <> (intercalate ", " (map compileOrderByClause orderByClauses))
3636
, orderByClauses
37-
|> map (\QueryBuilder.OrderByClause { orderByColumn, orderByDirection } ->
37+
|> map (\case
38+
OrderByClause { orderByColumn, orderByDirection } ->
3839
[ PG.toField $ PG.Identifier (fieldNameToColumnName $ cs orderByColumn)
3940
, PG.toField $ if orderByDirection == QueryBuilder.Desc
4041
then PG.Plain "DESC"
4142
else PG.Plain ""
4243
]
43-
)
44+
OrderByTSRank { tsvector, tsquery } ->
45+
[ PG.toField $ PG.Identifier (fieldNameToColumnName tsvector)
46+
, PG.toField tsquery
47+
]
48+
)
4449
|> concat
4550
)
4651

@@ -56,6 +61,10 @@ compileQuery DynamicSQLQuery { .. } = (sql, args)
5661
Just offset -> (" OFFSET ?", [PG.toField offset])
5762
Nothing -> ("", [])
5863

64+
compileOrderByClause :: OrderByClause -> Text
65+
compileOrderByClause OrderByClause {} = "? ?"
66+
compileOrderByClause OrderByTSRank { tsvector, tsquery } = "ts_rank(?, to_tsquery('english', ?))"
67+
5968
compileSelectedColumns :: SelectedColumns -> PG.Action
6069
compileSelectedColumns SelectAll = PG.Plain "*"
6170
compileSelectedColumns (SelectSpecific fields) = PG.Many args
@@ -93,6 +102,7 @@ compileCondition (LiteralExpression literal) = ("?", [toValue literal])
93102
toValue (DateTimeValue utcTime) = PG.toField utcTime
94103
toValue (PointValue point) = PG.toField point
95104
toValue Null = PG.toField PG.Null
105+
compileCondition (CallExpression { functionCall = ToTSQuery { text } }) = ("to_tsquery('english', ?)", [PG.toField text])
96106

97107
compileOperator :: ConditionOperator -> PG.Query
98108
compileOperator OpEqual = "="
@@ -104,4 +114,5 @@ compileOperator OpNotEqual = "<>"
104114
compileOperator OpAnd = "AND"
105115
compileOperator OpOr = "OR"
106116
compileOperator OpIs = "IS"
107-
compileOperator OpIsNot = "IS NOT"
117+
compileOperator OpIsNot = "IS NOT"
118+
compileOperator OpTSMatch = "@@"

IHP/DataSync/REST/Controller.hs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{-# LANGUAGE UndecidableInstances #-}
22
module IHP.DataSync.REST.Controller where
33

4-
import IHP.ControllerPrelude
4+
import IHP.ControllerPrelude hiding (OrderByClause)
55
import IHP.DataSync.REST.Types
66
import Data.Aeson
77
import Data.Aeson.TH

Test/DataSync/DynamicQueryCompiler.hs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import Test.Hspec
77
import IHP.Prelude
88
import IHP.DataSync.DynamicQueryCompiler
99
import IHP.DataSync.DynamicQuery
10-
import IHP.QueryBuilder
10+
import IHP.QueryBuilder hiding (OrderByClause)
1111
import qualified Database.PostgreSQL.Simple.ToField as PG
1212

1313
tests = do
@@ -164,4 +164,19 @@ tests = do
164164
compileQuery query `shouldBe`
165165
( "SELECT ? FROM ? WHERE (?) IS NOT NULL"
166166
, [PG.Plain "*", PG.EscapeIdentifier "posts", PG.EscapeIdentifier "user_id"]
167+
)
168+
169+
it "compile queries with TS expressions" do
170+
let query = DynamicSQLQuery
171+
{ table = "products"
172+
, selectedColumns = SelectAll
173+
, whereCondition = Just $ InfixOperatorExpression (ColumnExpression "ts") OpTSMatch (CallExpression { functionCall = ToTSQuery { text = "test" }})
174+
, orderByClause = [ OrderByTSRank { tsvector = "ts", tsquery = "test" } ]
175+
, limit = Nothing
176+
, offset = Nothing
177+
}
178+
179+
compileQuery query `shouldBe`
180+
( "SELECT ? FROM ? WHERE (?) @@ (to_tsquery('english', ?)) ORDER BY ts_rank(?, to_tsquery('english', ?))"
181+
, [PG.Plain "*", PG.EscapeIdentifier "products", PG.EscapeIdentifier "ts", PG.Escape "test", PG.EscapeIdentifier "ts", PG.Escape "test"]
167182
)

lib/IHP/DataSync/ihp-querybuilder.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,34 @@ class QueryBuilder {
341341
filterWhereGreaterThanOrEqual() {
342342
return this.whereGreaterThanOrEqual.apply(this, Array.from(arguments))
343343
}
344+
345+
whereTextSearchStartsWith(field, value) {
346+
let normalized = value.trim().split(' ').map(s => s.trim()).filter(v => v.length > 0).join('&');
347+
if (normalized.length > 0) {
348+
normalized += ':*';
349+
}
350+
const expression = {
351+
tag: 'InfixOperatorExpression',
352+
left: {
353+
tag: 'ColumnExpression',
354+
field,
355+
},
356+
op: 'OpTSMatch',
357+
right: {
358+
tag: 'CallExpression',
359+
functionCall: {
360+
tag: 'ToTSQuery',
361+
text: normalized
362+
}
363+
},
364+
};
365+
366+
this.#addWhereCondition('OpAnd', expression);
367+
368+
this.query.orderByClause.push({ tag: 'OrderByTSRank', tsvector: field, tsquery: normalized });
369+
370+
return this;
371+
}
344372

345373
or(conditionBuilder) {
346374
if (this.query.whereCondition === null) {

0 commit comments

Comments
 (0)