{-| Module: IHP.DataSync.DynamicQueryCompiler Description: Compiles a DynamicQuery to SQL Copyright: (c) digitally induced GmbH, 2021 -} module IHP.DataSync.DynamicQueryCompiler where import IHP.Prelude import IHP.DataSync.DynamicQuery import qualified IHP.QueryBuilder as QueryBuilder import qualified Database.PostgreSQL.Simple as PG import qualified Database.PostgreSQL.Simple.ToField as PG import qualified Database.PostgreSQL.Simple.Types as PG import qualified Data.List as List data Renamer = Renamer { fieldToColumn :: Text -> Text , columnToField :: Text -> Text } compileQuery :: DynamicSQLQuery -> (PG.Query, [PG.Action]) compileQuery = compileQueryWithRenamer camelCaseRenamer compileQueryWithRenamer :: Renamer -> DynamicSQLQuery -> (PG.Query, [PG.Action]) compileQueryWithRenamer renamer query = compileQueryMapped (mapColumnNames renamer.fieldToColumn query) -- | Default renamer used by DataSync. -- -- Transforms JS inputs in @camelCase@ to snake_case for the database -- and DB outputs in @snake_case@ back to @camelCase@ camelCaseRenamer :: Renamer camelCaseRenamer = Renamer { fieldToColumn = fieldNameToColumnName , columnToField = columnNameToFieldName } -- | Renamer that does not modify the column names unmodifiedRenamer :: Renamer unmodifiedRenamer = Renamer { fieldToColumn = id , columnToField = id } -- | When a Field is retrieved from the database, it's all in @snake_case@. This turns it into @camelCase@ renameField :: Renamer -> Field -> Field renameField renamer field = field { fieldName = renamer.columnToField field.fieldName } compileQueryMapped :: DynamicSQLQuery -> (PG.Query, [PG.Action]) compileQueryMapped DynamicSQLQuery { .. } = (sql, args) where sql = "SELECT" <> distinctOnSql <> "? FROM ?" <> whereSql <> orderBySql <> limitSql <> offsetSql args = distinctOnArgs <> catMaybes [ Just (compileSelectedColumns selectedColumns) , Just (PG.toField (PG.Identifier table)) ] <> whereArgs <> orderByArgs <> limitArgs <> offsetArgs (distinctOnSql, distinctOnArgs) = case distinctOnColumn of Just column -> (" DISTINCT ON (?) ", [PG.toField $ PG.Identifier (cs column)]) Nothing -> (" ", []) (orderBySql, orderByArgs) = case orderByClause of [] -> ("", []) orderByClauses -> ( PG.Query $ cs $ " ORDER BY " <> (intercalate ", " (map compileOrderByClause orderByClauses)) , orderByClauses |> map (\case OrderByClause { orderByColumn, orderByDirection } -> [ PG.toField $ PG.Identifier (cs orderByColumn) , PG.toField $ if orderByDirection == QueryBuilder.Desc then PG.Plain "DESC" else PG.Plain "" ] OrderByTSRank { tsvector, tsquery } -> [ PG.toField $ PG.Identifier tsvector , PG.toField tsquery ] ) |> concat ) (whereSql, whereArgs) = case compileCondition <$> whereCondition of Just (sql, args) -> (" WHERE " <> sql, args) Nothing -> ("", []) (limitSql, limitArgs) = case limit of Just limit -> (" LIMIT ?", [PG.toField limit]) Nothing -> ("", []) (offsetSql, offsetArgs) = case offset of Just offset -> (" OFFSET ?", [PG.toField offset]) Nothing -> ("", []) -- | Used to transform column names from @camelCase@ to @snake_case@ mapColumnNames :: (Text -> Text) -> DynamicSQLQuery -> DynamicSQLQuery mapColumnNames rename query = query { selectedColumns = mapSelectedColumns query.selectedColumns , whereCondition = mapConditionExpression <$> query.whereCondition , orderByClause = map mapOrderByClause query.orderByClause , distinctOnColumn = (cs . rename . cs) <$> query.distinctOnColumn } where mapSelectedColumns :: SelectedColumns -> SelectedColumns mapSelectedColumns SelectAll = SelectAll mapSelectedColumns (SelectSpecific columns) = SelectSpecific (map rename columns) mapConditionExpression :: ConditionExpression -> ConditionExpression mapConditionExpression ColumnExpression { field } = ColumnExpression { field = rename field } mapConditionExpression InfixOperatorExpression { left, op, right } = InfixOperatorExpression { left = mapConditionExpression left, op, right = mapConditionExpression right } mapConditionExpression otherwise = otherwise mapOrderByClause :: OrderByClause -> OrderByClause mapOrderByClause OrderByClause { orderByColumn, orderByDirection } = OrderByClause { orderByColumn = cs (rename (cs orderByColumn)), orderByDirection } mapOrderByClause otherwise = otherwise compileOrderByClause :: OrderByClause -> Text compileOrderByClause OrderByClause {} = "? ?" compileOrderByClause OrderByTSRank { tsvector, tsquery } = "ts_rank(?, to_tsquery('english', ?))" compileSelectedColumns :: SelectedColumns -> PG.Action compileSelectedColumns SelectAll = PG.Plain "*" compileSelectedColumns (SelectSpecific fields) = PG.Many args where args :: [PG.Action] args = List.intercalate ([PG.Plain ", "]) fieldActions fieldActions :: [[PG.Action]] fieldActions = (map (\field -> [ PG.toField (PG.Identifier field) ]) fields) -- TODO: validate query against schema compileCondition :: ConditionExpression -> (PG.Query, [PG.Action]) compileCondition (ColumnExpression column) = ("?", [PG.toField $ PG.Identifier column]) compileCondition (InfixOperatorExpression a OpEqual (LiteralExpression Null)) = compileCondition (InfixOperatorExpression a OpIs (LiteralExpression Null)) -- Turn 'a = NULL' into 'a IS NULL' compileCondition (InfixOperatorExpression a OpNotEqual (LiteralExpression Null)) = compileCondition (InfixOperatorExpression a OpIsNot (LiteralExpression Null)) -- Turn 'a <> NULL' into 'a IS NOT NULL' compileCondition (InfixOperatorExpression a OpIn (ListExpression { values })) | (Null `List.elem` values) = -- Turn 'a IN (NULL)' into 'a IS NULL' case partition ((/=) Null) values of ([], nullValues) -> compileCondition (InfixOperatorExpression a OpIs (LiteralExpression Null)) (nonNullValues, nullValues) -> compileCondition (InfixOperatorExpression (InfixOperatorExpression a OpIn (ListExpression { values = nonNullValues })) OpOr (InfixOperatorExpression a OpIs (LiteralExpression Null))) compileCondition (InfixOperatorExpression a operator b) = ("(" <> queryA <> ") " <> compileOperator operator <> " " <> rightOperand, paramsA <> paramsB) where (queryA, paramsA) = compileCondition a (queryB, paramsB) = compileCondition b rightOperand = if rightParentheses then "(" <> queryB <> ")" else queryB rightParentheses :: Bool rightParentheses = case b of LiteralExpression Null -> False ListExpression {} -> False -- The () are inserted already via @PG.In@ _ -> True compileCondition (LiteralExpression literal) = ("?", [PG.toField literal]) compileCondition (CallExpression { functionCall = ToTSQuery { text } }) = ("to_tsquery('english', ?)", [PG.toField text]) compileCondition (ListExpression { values }) = ("?", [PG.toField (PG.In values)]) compileOperator :: ConditionOperator -> PG.Query compileOperator OpEqual = "=" compileOperator OpGreaterThan = ">" compileOperator OpLessThan = "<" compileOperator OpGreaterThanOrEqual = ">=" compileOperator OpLessThanOrEqual = "<=" compileOperator OpNotEqual = "<>" compileOperator OpAnd = "AND" compileOperator OpOr = "OR" compileOperator OpIs = "IS" compileOperator OpIsNot = "IS NOT" compileOperator OpTSMatch = "@@" compileOperator OpIn = "IN" instance PG.ToField DynamicValue where toField (IntValue int) = PG.toField int toField (DoubleValue double) = PG.toField double toField (TextValue text) = PG.toField text toField (BoolValue bool) = PG.toField bool toField (UUIDValue uuid) = PG.toField uuid toField (DateTimeValue utcTime) = PG.toField utcTime toField (PointValue point) = PG.toField point toField (ArrayValue values) = PG.toField (PG.PGArray values) toField Null = PG.toField PG.Null