1
Fork 0
mirror of https://github.com/Airsequel/AirGQL.git synced 2025-07-09 11:39:33 +02:00
airgql/source/AirGQL/Lib.hs

1272 lines
34 KiB
Haskell

{-# LANGUAGE QuasiQuotes #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# HLINT ignore "Use list comprehension" #-}
{-# HLINT ignore "Replace case with maybe" #-}
{-# HLINT ignore "Use tuple-section" #-}
module AirGQL.Lib (
AccessMode (..),
ColumnEntry (..),
GqlTypeName (..),
getColumns,
getRowidColumnName,
getTables,
getTableNames,
getColumnNames,
getEnrichedTables,
ObjectType (..),
parseSql,
replaceCaseInsensitive,
sanitizeSql,
sqlDataToAesonValue,
sqlDataToText,
SQLPost (..),
sqlTypeNameToGQLTypeName,
TableEntryRaw (..),
TableEntry (..),
UniqueConstraint (..),
ReferencesConstraint (..),
ReferencesConstraintColumns (..),
CheckConstraint (..),
sqlite, -- useful for pretty printing
stringToGqlTypeName,
lintTableCreationCode,
resolveReferencesConstraintColumns,
resolveReferencesConstraint,
)
where
import Protolude (
Applicative (pure),
Bool (False, True),
Either (Left, Right),
Eq ((/=), (==)),
Exception (toException),
Generic,
IO,
Int,
Maybe (Just, Nothing),
Semigroup ((<>)),
Show,
Text,
notElem,
otherwise,
show,
($),
(&),
(&&),
(<$>),
(<&>),
(>>=),
(||),
)
import Protolude qualified as P
import AirGQL.Utils (collectAllErrorsAsText, quoteText)
import Control.Monad (MonadFail (fail))
import Control.Monad.Catch (catchAll)
import Data.Aeson (FromJSON, ToJSON, Value (Bool, Null, Number, String))
import Data.Scientific qualified as Scientific
import Data.Text (isInfixOf, toUpper)
import Data.Text qualified as T
import Database.SQLite.Simple (
Connection,
FromRow,
ResultError (ConversionFailed, errHaskellType, errMessage, errSQLType),
SQLData (SQLBlob, SQLFloat, SQLInteger, SQLNull, SQLText),
query_,
)
import Database.SQLite.Simple qualified as SS
import Database.SQLite.Simple.FromField (FromField (fromField), fieldData)
import Database.SQLite.Simple.Ok (Ok (Errors, Ok))
import Database.SQLite.Simple.QQ qualified as SS
import DoubleXEncoding (doubleXEncodeGql)
import Language.SQL.SimpleSQL.Dialect (
Dialect (
diAppKeywords,
diAutoincrement,
diBackquotedIden,
diKeywords,
diLimit,
diSquareBracketQuotedIden
),
ansi2011,
)
import Language.SQL.SimpleSQL.Parse (ParseError, parseStatement)
import Language.SQL.SimpleSQL.Pretty (prettyScalarExpr)
import Language.SQL.SimpleSQL.Syntax (
ColConstraint (ColCheckConstraint, ColNotNullConstraint),
ColConstraintDef (ColConstraintDef),
ColumnDef (ColumnDef),
InPredValue (InList),
ScalarExpr (In, NumLit, StringLit),
Statement (CreateTable),
TableElement (TableColumnDef),
)
import Language.SQL.SimpleSQL.Syntax qualified as SQL
import Servant.Docs (ToSample (toSamples), singleSample)
data AccessMode = ReadOnly | WriteOnly | ReadAndWrite
deriving (Eq, Show)
data ObjectType = Table | Index | View | Trigger
deriving (Show, Eq, Generic)
instance ToJSON ObjectType
instance FromJSON ObjectType
instance FromField ObjectType where
fromField fData = case fieldData fData of
SQLText "table" -> Ok Table
SQLText "index" -> Ok Index
SQLText "view" -> Ok View
SQLText "trigger" -> Ok Trigger
sqlData ->
Errors
[ toException $
ConversionFailed
{ errSQLType = "Object Type"
, errHaskellType = "String"
, errMessage =
"\"" <> show sqlData <> "\" is not a vaild object type"
}
]
data TableEntryRaw = TableEntryRaw
{ name :: Text
, tbl_name :: Text
, object_type :: ObjectType
, rootpage :: Int
, sql :: Text
}
deriving (Show, Eq, Generic)
instance ToJSON TableEntryRaw
instance FromRow TableEntryRaw
data UniqueConstraint = UniqueConstraint
{ name :: Maybe Text
, columns :: [Text]
}
deriving (Show, Eq, Generic)
instance ToJSON UniqueConstraint
data ReferencesConstraintColumns
= -- | The "to" column is implicit.
-- Eg: `a TEXT REFERENCES other_table`
ImplicitColumns Text
| -- | Explicit (from, to) pairs
ExplicitColumns [(Text, Text)]
deriving (Show, Eq, Generic)
instance ToJSON ReferencesConstraintColumns
data ReferencesConstraint = ReferencesConstraint
{ name :: Maybe Text
, table :: Text
, columns :: ReferencesConstraintColumns
}
deriving (Show, Eq, Generic)
instance ToJSON ReferencesConstraint
data CheckConstraint = CheckConstraint
{ name :: Maybe Text
, predicate :: Text
, columns :: Maybe [Text]
}
deriving (Show, Eq, Generic)
instance ToJSON CheckConstraint
data TableEntry = TableEntry
{ name :: Text
, tbl_name :: Text
, object_type :: ObjectType
, rootpage :: Int
, sql :: Text
, statement :: Statement
, uniqueConstraints :: [UniqueConstraint]
, referencesConstraints :: [ReferencesConstraint]
, checkConstraints :: [CheckConstraint]
, columns :: [ColumnEntry]
}
deriving (Show, Eq, Generic)
-- | As requested from SQLite
data ColumnEntryRaw = ColumnEntryRaw
{ cid :: Int
, column_name :: Text
, datatype :: Text
, notnull :: Int -- TODO: Should be boolean
, dflt_value :: Maybe Text
, primary_key :: Int -- TODO: Should be boolean
, -- See the docs for the different meanings:
-- https://www.sqlite.org/pragma.html#pragma_table_xinfo
-- - 0 means normal
-- - 1 means hidden column in a virtual table
-- - 2 and 3 mean generated columns
hidden :: Int
}
deriving (Show, Eq, Generic)
instance FromRow ColumnEntryRaw
data GqlTypeName = GqlTypeName
{ root :: Text
, full :: Text
}
deriving (Show, Eq, Generic)
instance ToJSON GqlTypeName
-- | Enhanced with generated information from SQL query "CREATE TABLE"
data ColumnEntry = ColumnEntry
{ column_name :: Text
, column_name_gql :: Text
, datatype :: Text
-- ^ double-X-encoded GQL identifiers
, datatype_gql :: Maybe GqlTypeName
, select_options :: Maybe [Text]
, notnull :: Bool
, isGenerated :: Bool
, isUnique :: Bool
, isOmittable :: Bool
-- ^ If column is NON NULL, but will be set automatically
, dflt_value :: Maybe Text
, primary_key :: Bool
}
deriving (Show, Eq, Generic)
instance ToJSON ColumnEntry
data ParsedTable = ParsedTable
{ uniqueConstraints :: [UniqueConstraint]
, referencesConstraints :: [ReferencesConstraint]
, checkConstraints :: [CheckConstraint]
, statement :: Statement
}
deriving (Show, Eq, Generic)
getTables :: Connection -> IO [TableEntryRaw]
getTables connection = do
query_
connection
[SS.sql|
SELECT name, tbl_name, type, rootpage, sql
FROM sqlite_master
WHERE
type == 'table' OR
type == 'view'
|]
:: IO [TableEntryRaw]
getTableNames :: Connection -> IO [Text]
getTableNames connection = do
results :: [SS.Only Text] <-
query_
connection
[SS.sql|
SELECT tbl_name
FROM sqlite_master
WHERE type='table' or type='view'
|]
pure (SS.fromOnly <$> results)
getColumnNames :: Connection -> Text -> IO [Text]
getColumnNames connection tableName = do
results :: [SS.Only Text] <-
query_
connection
$ SS.Query
$ "SELECT name FROM pragma_table_xinfo(" <> quoteText tableName <> ")"
pure (SS.fromOnly <$> results)
-- TODO: investigate whether we ever want to quote the result
nameAsText :: SQL.Name -> Text
nameAsText = \case
SQL.Name _ name -> T.pack name
getFirstName :: Maybe [SQL.Name] -> Maybe Text
getFirstName namesMb = do
names <- namesMb
first <- P.head names
pure (nameAsText first)
getColumnUniqueConstraint
:: Text
-> SQL.ColConstraintDef
-> Maybe UniqueConstraint
getColumnUniqueConstraint col_name = \case
SQL.ColConstraintDef names SQL.ColUniqueConstraint ->
Just $
UniqueConstraint
{ name = getFirstName names
, columns = [col_name]
}
_ -> Nothing
tableUniqueConstraints :: SQL.TableElement -> [UniqueConstraint]
tableUniqueConstraints = \case
SQL.TableConstraintDef names (SQL.TableUniqueConstraint columns) ->
[ UniqueConstraint
{ name = getFirstName names
, columns = P.fmap nameAsText columns
}
]
SQL.TableColumnDef (SQL.ColumnDef col_name _ _ constraints) ->
P.mapMaybe (getColumnUniqueConstraint (nameAsText col_name)) constraints
_ -> []
getColumnCheckConstraint
:: Text
-> SQL.ColConstraintDef
-> Maybe CheckConstraint
getColumnCheckConstraint col_name = \case
SQL.ColConstraintDef names (SQL.ColCheckConstraint expr) ->
Just $
CheckConstraint
{ name = getFirstName names
, columns = Just [col_name]
, predicate = T.pack $ prettyScalarExpr sqlite expr
}
_ -> Nothing
tableCheckConstraints :: SQL.TableElement -> [CheckConstraint]
tableCheckConstraints = \case
SQL.TableConstraintDef names (SQL.TableCheckConstraint expr) ->
[ CheckConstraint
{ name = getFirstName names
, predicate = T.pack $ prettyScalarExpr sqlite expr
, -- not sure how to do this properly
columns = Nothing
}
]
SQL.TableColumnDef (SQL.ColumnDef col_name _ _ constraints) ->
P.mapMaybe (getColumnCheckConstraint (nameAsText col_name)) constraints
_ -> []
getColumnReferencesConstraint
:: Text
-> SQL.ColConstraintDef
-> P.Either Text (Maybe ReferencesConstraint)
getColumnReferencesConstraint col_name = \case
SQL.ColConstraintDef
names
(SQL.ColReferencesConstraint table_names foreign_col_name _ _ _) -> do
table_name <-
P.note "Column references constraint has no table name" $
P.head table_names
pure $
Just $
ReferencesConstraint
{ name = getFirstName names
, table = nameAsText table_name
, columns = case foreign_col_name of
Just explicit_col_name ->
ExplicitColumns [(col_name, nameAsText explicit_col_name)]
Nothing ->
ImplicitColumns col_name
}
_ -> pure Nothing
tableReferencesConstraints
:: SQL.TableElement
-> P.Either Text [ReferencesConstraint]
tableReferencesConstraints = \case
SQL.TableConstraintDef
names
( SQL.TableReferencesConstraint
self_columns
table_names
foreign_columns
_
_
_
) -> do
table_name <-
P.note "Table references constraint has no table name" $
P.head table_names
columns <- case (self_columns, foreign_columns) of
([column], Nothing) ->
pure $ ImplicitColumns (nameAsText column)
(_, Nothing) ->
P.throwError
"References constraints where more than one column is \
\implicit are not supported"
(columns, Just many_foreign_columns) -> do
P.when (P.length columns /= P.length many_foreign_columns) $ do
P.throwError
"Number of columns in references constraint \
\must be equal"
pure $
ExplicitColumns $
P.zip
(P.fmap nameAsText columns)
(P.fmap nameAsText many_foreign_columns)
pure
[ ReferencesConstraint
{ name = getFirstName names
, table = nameAsText table_name
, columns = columns
}
]
SQL.TableColumnDef (SQL.ColumnDef col_name _ _ constraints) ->
-- => [ColumnConstraint]
constraints
-- => [Either Text (Maybe ColumnConstraint)]
<&> getColumnReferencesConstraint (nameAsText col_name)
-- => Either Text [Maybe ColumnConstraint]
& collectAllErrorsAsText
-- => Either Text [ColumnConstraint]
<&> P.catMaybes
_ -> pure []
getTableUniqueIndexConstraints :: SS.Connection -> Text -> IO [UniqueConstraint]
getTableUniqueIndexConstraints connection tableName = do
indices :: [[SQLData]] <-
catchAll
( SS.query
connection
[SS.sql|
SELECT sql
FROM sqlite_master
WHERE tbl_name = ? AND type = 'index'
|]
[tableName]
)
(\_ -> pure [])
indices
<&> \case
[SQLText sqlTxt]
-- Get column name from SQL query
| P.Right (SQL.CreateIndex True indexNames _ columns) <-
parseSql sqlTxt -> do
Just $
UniqueConstraint
{ name = nameAsText <$> P.head indexNames
, columns = nameAsText <$> columns
}
_ -> Nothing
& P.catMaybes
& pure
getSqlObjectName :: Statement -> Maybe Text
getSqlObjectName = \case
SQL.CreateTable names _ ->
names
& P.head
<&> nameAsText
SQL.CreateView _ _ names _ _ ->
names
>>= P.head
<&> nameAsText
_ -> Nothing
{-| Collects the different kinds of constraints found in a sql statement.
An optional connection can be used to read existing indices for unique
constraints of columns added after table creation.
-}
collectTableConstraints
:: Maybe SS.Connection
-> Statement
-> IO (P.Either Text ParsedTable)
collectTableConstraints connectionMb statement = do
uniqueIndices <- case (connectionMb, getSqlObjectName statement) of
(Just conn, Just name) -> getTableUniqueIndexConstraints conn name
_ -> pure []
case statement of
CreateTable _ elements -> do
let referencesConstraintsEither =
-- => [TableElemenet]
elements
-- => [Either Text TableElemenet]
& P.fmap tableReferencesConstraints
-- => Either Text [[TableElemenet]]
& collectAllErrorsAsText
-- => Either Text [TableElemenet]
& P.fmap P.join
P.for referencesConstraintsEither $ \referencesConstraints -> do
pure $
ParsedTable
{ uniqueConstraints =
uniqueIndices
<> (elements >>= tableUniqueConstraints)
, referencesConstraints = referencesConstraints
, checkConstraints = elements >>= tableCheckConstraints
, statement = statement
}
_ ->
pure $
P.Right $
ParsedTable
{ uniqueConstraints = uniqueIndices
, referencesConstraints = []
, checkConstraints = []
, statement = statement
}
enrichTableEntry
:: SS.Connection
-> TableEntryRaw
-> IO (P.Either Text TableEntry)
enrichTableEntry connection tableEntry@(TableEntryRaw{..}) =
case parseSql tableEntry.sql of
P.Left err -> pure $ P.Left (show err)
P.Right sqlStatement ->
collectTableConstraints (Just connection) sqlStatement
<&> P.fmap
( \(ParsedTable{..}) ->
TableEntry{columns = [], ..}
)
getEnrichedTables :: Connection -> IO (P.Either Text [TableEntry])
getEnrichedTables connection = do
tables <- getTables connection
enriched <- P.for tables $ \table -> do
enrichedEither <- enrichTableEntry connection table
P.for enrichedEither $ \enriched@TableEntry{..} -> do
tableColumns <-
getColumnsFromParsedTableEntry
connection
enriched
pure $
TableEntry
{ columns = tableColumns
, ..
}
pure $ collectAllErrorsAsText enriched
{-| SQLite allows references constraints to not specify the exact column they
are referencing. This functions tries to recover that information by
looking for primary keys among the columns of the referenced table.
Note: we currently do not support having composite primary keys
referenced implicitly, as that would lead to multiple complications like:
- figuring out the correct order for the references
- having to perform the "enrichTableEntry" computation in two separate passes
-}
resolveReferencesConstraint :: [TableEntry] -> Text -> Maybe Text
resolveReferencesConstraint tables referencedTable =
-- => [(TableEntry, [ColumnEntry])]
tables
-- => Maybe (TableEntry, [ColumnEntry])
& P.find (\table -> table.tbl_name == referencedTable)
-- => Maybe [ColumnEntry]
<&> (\table -> table.columns)
-- => Maybe ColumnEntry
>>= P.find (\column -> column.primary_key)
-- => Maybe Text
<&> (.column_name)
-- See the docs for `resolveReferencesConstraint` for details
resolveReferencesConstraintColumns
:: [TableEntry]
-> ReferencesConstraint
-> Maybe [(Text, Text)]
resolveReferencesConstraintColumns allEntries constraint =
case constraint.columns of
ExplicitColumns explicit -> Just explicit
ImplicitColumns from ->
case resolveReferencesConstraint allEntries constraint.table of
Just to -> Just [(from, to)]
Nothing -> Nothing
-- | Returns a set of warnings related to a given table.
lintTable :: [TableEntry] -> ParsedTable -> [Text]
lintTable allEntries parsed =
let
rowidReferenceWarnings =
parsed.referencesConstraints
& P.mapMaybe
( \constraint ->
resolveReferencesConstraintColumns allEntries constraint
& P.fromMaybe []
& P.find (\(_, to) -> to == "rowid")
<&> \case
(from, _to) ->
"Column "
<> quoteText from
<> " references the rowid column of table "
<> quoteText constraint.table
<> ".\n"
<> "This is not supported by SQLite:\n"
<> "https://www.sqlite.org/foreignkeys.html"
)
in
rowidReferenceWarnings
{-| Lint the sql code for creating a table
An optional connection can be used to retrieve the existing db data, which
is used for things like resolving implicit references constraints (where
the primary key is not explicitly given)
-}
lintTableCreationCode :: Maybe SS.Connection -> Statement -> IO [Text]
lintTableCreationCode connectionMb statement = do
constraintsEither <- collectTableConstraints connectionMb statement
allEntriesEither <- case connectionMb of
Just connection -> getEnrichedTables connection
Nothing -> pure $ Right []
pure $ case (constraintsEither, allEntriesEither) of
(Right _, Left err) -> [err]
(Left err, Right _) -> [err]
(Left errL, Left errR) -> [errL, errR]
(Right parsed, Right allEntries) ->
lintTable allEntries parsed
getRowidColumnName :: [Text] -> Text
getRowidColumnName colNames
| "rowid" `notElem` colNames = "rowid"
| "_rowid_" `notElem` colNames = "_rowid_"
| "oid" `notElem` colNames = "oid"
| otherwise = "rowid" -- TODO: Return error to user
columnDefName :: ColumnDef -> Text
columnDefName (ColumnDef name _ _ _) = nameAsText name
-- Computes whether a column is NOT NULL
columnIsNonNull :: SQL.ColumnDef -> Bool
columnIsNonNull (ColumnDef _ _ _ constraints) =
let isNotNullConstraint = \case
ColConstraintDef _ ColNotNullConstraint -> True
_ -> False
in P.any isNotNullConstraint constraints
-- For a single column, returns selectable values
-- E.g. ("color", (SelectOptions ["red", "green", "blue"]))
columnSelectOptions :: SQL.ColumnDef -> Maybe SelectOptions
columnSelectOptions (ColumnDef _ _ _ colConstraints) =
let
getSelectOptions
:: ColConstraintDef
-> Maybe SelectOptions
getSelectOptions = \case
ColConstraintDef
_
(ColCheckConstraint (In _ _ (InList options))) ->
let
textOnlyOptions =
options
<&> \case
StringLit _ _ value ->
T.pack value
NumLit value ->
T.pack value
_ -> "UNSUPPORTED"
in
Just (SelectOptions textOnlyOptions)
_ -> Nothing
in
colConstraints
& P.mapMaybe getSelectOptions
& P.head
getColumnsFromParsedTableEntry
:: Connection
-> TableEntry
-> IO [ColumnEntry]
getColumnsFromParsedTableEntry connection tableEntry = do
keyColumns :: [[SQLData]] <-
query_ connection $
SS.Query $
"SELECT * FROM pragma_index_info("
<> quoteText tableEntry.tbl_name
<> ")"
-- TODO: Catch only SQL specific exceptions
colEntriesRaw :: [ColumnEntryRaw] <-
catchAll
( query_ connection $
SS.Query $
"SELECT * FROM pragma_table_xinfo("
<> quoteText tableEntry.tbl_name
<> ")"
)
( \exception -> do
P.putErrText $ show exception
pure []
)
let
tableElementsMb = case tableEntry.statement of
SQL.CreateTable _ tableElements ->
Just tableElements
_ -> Nothing
columnDefs = case tableElementsMb of
Just tableElements ->
tableElements
<&> \case
TableColumnDef columnDef -> Just columnDef
_ -> Nothing
& P.catMaybes
Nothing -> []
-- As described here: https://www.sqlite.org/withoutrowid.html (Point 5)
hasRowId :: Bool
hasRowId = P.null keyColumns
colNames :: [Text]
colNames = colEntriesRaw <&> \c -> c.column_name
rowIdColName :: Text
rowIdColName = getRowidColumnName colNames
rowIdColumnEntry :: ColumnEntry
rowIdColumnEntry =
ColumnEntry
{ column_name = rowIdColName
, column_name_gql = rowIdColName
, datatype = "INTEGER"
, datatype_gql = Just $ stringToGqlTypeName "Int"
, select_options = P.Nothing
, -- While the rowid is actually NOT NULL,
-- it must be set to false here
-- to show in the GraphQL docs that it can be omitted
-- since it will be set automatically.
notnull = False
, isUnique = False
, isOmittable = True
, isGenerated = False
, dflt_value = P.Nothing
, primary_key = True
}
let
entries =
colEntriesRaw <&> \(ColumnEntryRaw{..}) -> do
let
columnDefMb = P.find (\d -> columnDefName d == column_name) columnDefs
selectOpts = columnDefMb >>= columnSelectOptions
ColumnEntry
{ column_name_gql = doubleXEncodeGql column_name
, datatype_gql =
sqlTypeNameToGQLTypeName
datatype
( P.const
(tableEntry.tbl_name <> "_" <> column_name)
<$> selectOpts
)
, select_options = selectOpts <&> unSelectOptions
, isUnique =
P.any
(\constraint -> column_name `P.elem` constraint.columns)
tableEntry.uniqueConstraints
, primary_key = primary_key == 1
, isOmittable =
(primary_key == 1 && T.isPrefixOf "int" (T.toLower datatype))
|| P.isJust dflt_value
, notnull =
notnull == 1 || case columnDefMb of
Just columnDef -> columnIsNonNull columnDef
Nothing -> False
, -- See the comment on the `hidden` property of
-- the `ColumnEntryRaw` type for an explanation.
isGenerated = hidden == 2 || hidden == 3
, ..
}
-- Views don't have a rowid column
-- (https://stackoverflow.com/q/38519169)
rowidColumns =
if hasRowId && tableEntry.object_type /= View
then [rowIdColumnEntry]
else []
pure $ rowidColumns <> entries
getColumns :: Text -> Connection -> Text -> IO [ColumnEntry]
getColumns dbId connection tableName =
let
columns = do
tables :: [TableEntryRaw] <-
SS.query
connection
[SS.sql|
SELECT name, tbl_name, type, rootpage, sql
FROM sqlite_master
WHERE name == ?
|]
[tableName]
table <- case P.head tables of
Just table -> pure table
Nothing ->
fail $
P.fold
[ "Could not find table info for table "
, T.unpack tableName
, " of db "
, T.unpack dbId
]
enrichmentResultEither <- enrichTableEntry connection table
enrichingResult <- case enrichmentResultEither of
Right result -> pure result
Left err ->
fail $
P.fold
[ "An error occurred while parsing table "
, T.unpack tableName
, " of db "
, T.unpack dbId
, ": "
, T.unpack err
]
getColumnsFromParsedTableEntry connection enrichingResult
in
catchAll
columns
$ \err -> do
P.putErrText $ P.show err
pure []
newtype SelectOptions = SelectOptions {unSelectOptions :: [Text]}
deriving (Show, Eq, Generic)
stringToGqlTypeName :: Text -> GqlTypeName
stringToGqlTypeName name = GqlTypeName{full = name, root = name}
{-| Computes storage class through type affinity
as described in https://www.sqlite.org/datatype3.html#affname
with an extension for boolean (Order is important)
TODO: Add Support for GraphQL's type "ID"
-}
sqlTypeNameToGQLTypeName :: Text -> Maybe Text -> Maybe GqlTypeName
sqlTypeNameToGQLTypeName sqliteType typeNameMb =
let
containsText text =
isInfixOf text $ toUpper sqliteType
rootType
-- If it is a view, column might not have a type
| sqliteType == "" = Nothing
| containsText "INT" = Just "Int"
| containsText "CHAR" || containsText "CLOB" || containsText "TEXT" =
Just "String"
| containsText "BLOB" = Just "String"
| containsText "REAL" || containsText "FLOA" || containsText "DOUB" =
Just "Float"
| containsText "BOOL" = Just "Boolean"
| otherwise = Just "Int"
in
rootType <&> \root ->
GqlTypeName
{ root = root
, full = case typeNameMb of
P.Just typeName -> doubleXEncodeGql (typeName <> "_" <> root)
P.Nothing -> root
}
sqlDataToText :: SQLData -> Text
sqlDataToText = \case
SQLInteger int64 -> show int64
SQLFloat double -> show double
SQLText text -> text
SQLBlob _ -> "BLOB"
SQLNull -> "NULL"
-- | WARNING: Also change duplicate `sqlDataToGQLValue`
sqlDataToAesonValue :: Text -> SQLData -> Value
sqlDataToAesonValue datatype sqlData = case sqlData of
SQLInteger int64 ->
if isInfixOf "BOOL" $ toUpper datatype
then case int64 of
0 -> Bool False
_ -> Bool True
else Number $ P.fromIntegral int64 -- Int32
SQLFloat double -> Number $ Scientific.fromFloatDigits double
SQLText text -> String text
SQLBlob byteString -> String $ show byteString
SQLNull -> Null
{-| Case-insensitively replaces all occurrences of a substring within a string
with a replacement string.
Examples:
>>> replaceCaseInsensitive "hello" "hi" "Hello World"
"hi World"
>>> replaceCaseInsensitive "l" "L" "Hello World"
"HeLLo WorLd"
-}
replaceCaseInsensitive :: Text -> Text -> Text -> Text
replaceCaseInsensitive removable replacement txt =
let
len = T.length removable
process remaining result
| T.null remaining = result
| (remaining & T.take len & T.toLower) == (removable & T.toLower) =
process (remaining & T.drop len) (result <> replacement)
| otherwise =
process (remaining & T.drop 1) (result <> T.take 1 remaining)
in
process txt ""
{-| Replace rem(movable) with rep(lacement)
| and make sure its surrounded by spaces
-}
replaceWithSpace :: Text -> Text -> Text -> Text
replaceWithSpace rem rep txt =
txt
& replaceCaseInsensitive (" " <> rem <> " ") (" " <> rep <> " ")
& replaceCaseInsensitive (" " <> rem <> "\n") (" " <> rep <> "\n")
& replaceCaseInsensitive ("\n" <> rem <> " ") ("\n" <> rep <> " ")
& replaceCaseInsensitive ("\n" <> rem <> "\n") ("\n" <> rep <> "\n")
sanitizeSql :: Text -> Text
sanitizeSql sql =
sql
-- TODO: Remove after
-- https://github.com/JakeWheat/simple-sql-parser/issues/27
& replaceWithSpace "if not exists" ""
-- TOOD: Remove after
-- https://github.com/JakeWheat/simple-sql-parser/issues/37
& replaceCaseInsensitive "insert or abort " "insert "
& replaceCaseInsensitive "insert or fail " "insert "
& replaceCaseInsensitive "insert or ignore " "insert "
& replaceCaseInsensitive "insert or replace " "insert "
& replaceCaseInsensitive "insert or rollback " "insert "
-- Removing the JSON arrow operator seems to be enough
-- to make the parser accept all queries containing JSON operators
& T.replace "->" ""
-- https://www.sqlite.org/stricttables.html
& replaceCaseInsensitive ")strict" ")"
& replaceCaseInsensitive ") strict" ")"
& replaceCaseInsensitive ")\nstrict" ")"
& replaceCaseInsensitive ") \nstrict" ")"
-- TODO: Remove after
-- https://github.com/JakeWheat/simple-sql-parser/issues/20
& ( \sqlQuery ->
if P.all
(\word -> word `P.elem` T.words (T.toLower sqlQuery))
["alter", "table", "rename"]
then "SELECT 0" -- Dummy statement to accept the query
else sqlQuery
)
-- TODO: Remove after
-- https://github.com/JakeWheat/simple-sql-parser/issues/41
& ( \sqlQuery ->
if P.all
(\word -> word `P.elem` T.words (T.toLower sqlQuery))
["create", "trigger", "on", "begin", "end"]
then "SELECT 0" -- Dummy statement to accept the query
else sqlQuery
)
& replaceCaseInsensitive "drop trigger" "drop table"
& replaceCaseInsensitive "drop index" "drop table"
-- Uncomment unsupported "RETURNING" clause
-- TODO: Add support for DELETE and UPDATE with RETURNING
-- TODO: Remove after
-- https://github.com/JakeWheat/simple-sql-parser/issues/42
& replaceCaseInsensitive ")returning " ") -- returning "
& replaceCaseInsensitive ") returning " ") -- returning "
& replaceCaseInsensitive ")\nreturning " ")\n-- returning "
& replaceCaseInsensitive ") \nreturning " ")\n-- returning "
-- TODO: Remove after
-- https://github.com/JakeWheat/simple-sql-parser/issues/43
& replaceWithSpace "==" "="
& replaceWithSpace "is not" "%$@_TEMP_@$%"
& replaceWithSpace "is" "="
& replaceWithSpace "%$@_TEMP_@$%" "is not"
-- The internal table is created without column types
-- TODO: Remove after
-- https://github.com/JakeWheat/simple-sql-parser/issues/38#issuecomment-1413340116
& replaceCaseInsensitive
"sqlite_sequence(name,seq)"
"sqlite_sequence(name TEXT,seq INT)"
-- TODO: Remove after
-- https://github.com/JakeWheat/simple-sql-parser/issues/40
& replaceWithSpace "NOT NULL DEFAULT" "DEFAULT"
-- TODO: Remove after
-- https://github.com/JakeWheat/simple-sql-parser/issues/46
& replaceCaseInsensitive "STORED" ""
& replaceCaseInsensitive "VIRTUAL" ""
& replaceWithSpace "GLOB" "LIKE"
-- | SQLite dialect
sqlite :: Dialect
sqlite =
ansi2011
{ diLimit = True
, diAutoincrement = True
, diAppKeywords =
ansi2011.diAppKeywords
<> [ "abs"
, -- https://www.sqlite.org/lang_mathfunc.html
"acos"
, "acosh"
, "asin"
, "asinh"
, "atan"
, "atan2"
, "atanh"
, "ceil"
, "ceiling"
, "cos"
, "cosh"
, "degrees"
, "exp"
, "floor"
, "ln"
, "log"
, "log"
, "log10"
, "log2"
, "mod"
, "pi"
, "pow"
, "power"
, "radians"
, "sin"
, "sinh"
, "sqrt"
, "tan"
, "tanh"
, "trunc"
]
, diKeywords =
[ "abort"
, "action"
, "add"
, "after"
, "all"
, "alter"
, "always"
, "analyze"
, "and"
, "as"
, "asc"
, "attach"
, "autoincrement"
, "before"
, "begin"
, "between"
, "by"
, "cascade"
, "case"
, "cast"
, "check"
, "collate"
, "column"
, "commit"
, "conflict"
, "constraint"
, "create"
, "cross"
, "current"
, "current_date"
, "current_time"
, "current_timestamp"
, "database"
, "default"
, "deferrable"
, "deferred"
, "delete"
, "desc"
, "detach"
, "distinct"
, "do"
, "drop"
, "each"
, "else"
, "end"
, "escape"
, "except"
, "exclude"
, "exclusive"
, "exists"
, "explain"
, "fail"
, "filter"
, "first"
, "following"
, "for"
, "foreign"
, "from"
, "full"
, "generated"
, "glob"
, "group"
, "groups"
, "having"
, "if"
, "ignore"
, "immediate"
, "in"
, "index"
, "indexed"
, "initially"
, "inner"
, "insert"
, "instead"
, "intersect"
, "into"
, "is"
, "isnull"
, "join"
, "key"
, "last"
, "left"
, "like"
, "limit"
, "match"
, "materialized"
, "natural"
, "no"
, "not"
, "nothing"
, "notnull"
, -- although "null" is on the official list of keywords, adding it here
-- seems to break "select NULL as ..." statemenets
-- , "null"
"nulls"
, "of"
, "offset"
, "on"
, "or"
, "order"
, "others"
, "outer"
, "over"
, "partition"
, "plan"
, "pragma"
, "preceding"
, "primary"
, "query"
, "raise"
, "range"
, "recursive"
, "references"
, "regexp"
, "reindex"
, "release"
, "rename"
, "replace"
, "restrict"
, "returning"
, "right"
, "rollback"
, "row"
, "rows"
, "savepoint"
, "select"
, "set"
, "table"
, "temp"
, "temporary"
, "then"
, "ties"
, "to"
, "transaction"
, "trigger"
, "unbounded"
, "union"
, "unique"
, "update"
, "using"
, "vacuum"
, "values"
, "view"
, "virtual"
, "when"
, "where"
, "window"
, "with"
, "without"
]
, diBackquotedIden = True -- https://sqlite.org/lang_keywords.html
, diSquareBracketQuotedIden = True -- https://sqlite.org/lang_keywords.html
}
parseSql :: Text -> P.Either ParseError Statement
parseSql sqlQuery =
parseStatement sqlite "" P.Nothing $
T.unpack $
sanitizeSql sqlQuery
newtype SQLPost = SQLPost
{ query :: Text
}
deriving (Eq, Show, Generic)
instance ToJSON SQLPost
instance FromJSON SQLPost
instance ToSample AirGQL.Lib.SQLPost where
toSamples _ = singleSample $ SQLPost "SELECT * FROM users"