268 lines
6.8 KiB
Haskell
Executable file
268 lines
6.8 KiB
Haskell
Executable file
#!/usr/bin/env cabal
|
|
{- cabal:
|
|
ghc-options: -Wall
|
|
build-depends:
|
|
base,
|
|
text,
|
|
sqlite-easy,
|
|
twain,
|
|
warp,
|
|
bytestring,
|
|
wai-extra,
|
|
-}
|
|
|
|
{-
|
|
|
|
Gist accompanying https://alloca.space/blog/haskell-lang.html
|
|
|
|
Build and Run
|
|
-------------
|
|
|
|
1. Install GHC and Cabal using https://haskell.org/ghcup
|
|
2. Add execution permissions `chmod +x literest`
|
|
3. Run with `./literest.hs` (wait for it to download packages and compile, and for the listening message to display)
|
|
|
|
Setup Database
|
|
--------------
|
|
|
|
Create tables in literest using sqlite3, for example:
|
|
|
|
```
|
|
create table user(id int, name text, email text);
|
|
insert into user values (1, 'haskell', 'haskell@haskell.org');
|
|
insert into user values (2, 'Dean', 'dean@supernatural.com');
|
|
insert into user values (3, 'Sammy', 'sam@supernatural.com');
|
|
```
|
|
|
|
Run a query
|
|
-----------
|
|
|
|
Use `curl` to run a query and `jq` to format the results
|
|
|
|
```
|
|
curl "http://localhost:8888/user?project=name,email&select=or(id.eq.2,name.eq.Sammy)" | jq
|
|
```
|
|
|
|
-}
|
|
|
|
{-# language GHC2024, OverloadedStrings #-}
|
|
|
|
import Data.Char (isAlphaNum)
|
|
import Data.String
|
|
import Data.Text qualified as T
|
|
import Data.ByteString.Lazy qualified as BL
|
|
import Data.Text.Encoding qualified as T
|
|
|
|
import Database.Sqlite.Easy qualified as Sqlite
|
|
|
|
import Web.Twain qualified as Twain
|
|
import Network.Wai.Handler.Warp qualified as Warp
|
|
import qualified Network.Wai.Middleware.RequestLogger as Logger
|
|
import Control.Monad.IO.Class (liftIO)
|
|
|
|
main :: IO ()
|
|
main = do
|
|
pool <- Sqlite.createSqlitePool "./literest.db"
|
|
putStrLn "Server running at http://localhost:8888 (ctrl-c to quit)"
|
|
Warp.run 8888 (Logger.logStdoutDev $ app pool)
|
|
|
|
-----------------------------------
|
|
-- * Server
|
|
|
|
app :: Sqlite.Pool Sqlite.Database -> Twain.Application
|
|
app dbpool =
|
|
(Twain.get "/:rootField" (handleRootField dbpool))
|
|
(Twain.notFound (Twain.send (Twain.text "404 Not Found.")))
|
|
|
|
handleRootField :: Sqlite.Pool Sqlite.Database -> Twain.ResponderM a
|
|
handleRootField dbpool = do
|
|
rootField <- Twain.param "rootField"
|
|
projection <- parseProjection <$> Twain.paramMaybe "project"
|
|
selection <- parseSelection =<< Twain.paramMaybe "select"
|
|
|
|
let
|
|
query = Query (Name rootField) projection selection
|
|
sql = compile query
|
|
|
|
liftIO (print sql)
|
|
|
|
res <- liftIO (runSql dbpool sql)
|
|
|
|
case res of
|
|
[[Sqlite.SQLText result]] ->
|
|
Twain.send $
|
|
Twain.raw
|
|
Twain.ok200
|
|
[(Twain.hContentType, "application/json; charset=utf-8")]
|
|
(BL.fromStrict (T.encodeUtf8 result))
|
|
_ ->
|
|
error "Internal error."
|
|
|
|
------------------------------------------
|
|
-- * Model
|
|
-- We use types do specify our model
|
|
|
|
-- | A Name of a table or a column
|
|
newtype Name = Name T.Text
|
|
deriving Show
|
|
|
|
-- | A definition of a query
|
|
data Query
|
|
= Query
|
|
{ rootField :: Name
|
|
, projection :: [Name]
|
|
, selection :: BooleanExpression
|
|
}
|
|
deriving Show
|
|
|
|
-- | A boolean expression for selection
|
|
data BooleanExpression
|
|
= Eq Name Value
|
|
| And [BooleanExpression]
|
|
| Or [BooleanExpression]
|
|
| Not BooleanExpression
|
|
deriving Show
|
|
|
|
-- | A scalar value
|
|
data Value
|
|
= Text T.Text
|
|
deriving Show
|
|
|
|
type Error = String
|
|
|
|
------------------------------------------
|
|
-- * SQL
|
|
-- data type for parameterised sql strings combined with values
|
|
|
|
data SQL = SQL String [Value]
|
|
deriving Show
|
|
|
|
instance Semigroup SQL where
|
|
(<>) (SQL str1 vals1) (SQL str2 vals2) =
|
|
SQL (str1 <> str2) (vals1 <> vals2)
|
|
|
|
instance Monoid SQL where
|
|
mempty = SQL "" []
|
|
|
|
instance IsString SQL where
|
|
fromString str = SQL str []
|
|
|
|
(<+>) :: SQL -> SQL -> SQL
|
|
(<+>) s1 s2 = s1 <> " " <> s2
|
|
|
|
unwordsSQL :: [SQL] -> SQL
|
|
unwordsSQL = intercalate " "
|
|
|
|
intercalate :: Monoid m => m -> [m] -> m
|
|
intercalate divider = \case
|
|
x : y : xs -> x <> divider <> intercalate divider (y:xs)
|
|
xs -> mconcat xs
|
|
|
|
------------------------------------------
|
|
-- * Compiler
|
|
|
|
-- | Compile a graphql query to SQL
|
|
compile :: Query -> SQL
|
|
compile query =
|
|
case query of
|
|
Query { rootField, projection, selection } ->
|
|
unwordsSQL
|
|
[ "SELECT"
|
|
, "json_group_array(json_object(" <>
|
|
intercalate ", " (map compileNameProj projection)
|
|
<> "))"
|
|
, "FROM"
|
|
, compileName rootField
|
|
, "WHERE"
|
|
, compileBoolExpr selection
|
|
]
|
|
|
|
-- | Convert a boolean expression to a SQL fragment
|
|
compileBoolExpr :: BooleanExpression -> SQL
|
|
compileBoolExpr = \case
|
|
Eq field value ->
|
|
compileName field <+> "=" <+> compileValue value
|
|
And [] ->
|
|
"true"
|
|
And exprs ->
|
|
"(" <> intercalate " AND " (map compileBoolExpr exprs) <> ")"
|
|
Or [] ->
|
|
"false"
|
|
Or exprs ->
|
|
"(" <> intercalate " OR " (map compileBoolExpr exprs) <> ")"
|
|
Not expr ->
|
|
"NOT" <+> "(" <> compileBoolExpr expr <> ")"
|
|
|
|
-- | Convert a value to a SQL fragment
|
|
compileValue :: Value -> SQL
|
|
compileValue value = SQL "?" [value]
|
|
|
|
-- | Convert a field name
|
|
compileName :: Name -> SQL
|
|
compileName (Name field)
|
|
| not (T.all isAlphaNum field) =
|
|
error $ "Field '" <> T.unpack field <> "' contains non alpha numeric characters"
|
|
| otherwise = SQL (T.unpack field) []
|
|
|
|
-- | Convert a field name as a key - value mapping for json_object
|
|
compileNameProj :: Name -> SQL
|
|
compileNameProj field =
|
|
"'" <> compileName field <> "', " <> compileName field
|
|
|
|
-----------------------------------
|
|
-- * Request Parsing
|
|
|
|
parseProjection :: Maybe T.Text -> [Name]
|
|
parseProjection =
|
|
maybe [] id . fmap (map Name . T.split (',' ==))
|
|
|
|
parseSelection =
|
|
maybe (pure $ And []) (either error pure . parseBooleanExpression)
|
|
|
|
parseBooleanExpression :: T.Text -> Either Error BooleanExpression
|
|
parseBooleanExpression text =
|
|
case T.break (`elem` ['.', '(']) text of
|
|
(_, "") -> Left "Unsupported format."
|
|
(field, value)
|
|
| field == "not" -> do
|
|
values <- parseComplex value
|
|
case values of
|
|
[v] -> Right (Not v)
|
|
_ -> Left "Not accepts exactly one boolean expression"
|
|
|
|
| field == "and" ->
|
|
And <$> parseComplex value
|
|
|
|
| field == "or" ->
|
|
Or <$> parseComplex value
|
|
|
|
| otherwise ->
|
|
parseField field value
|
|
|
|
parseComplex :: T.Text -> Either Error [BooleanExpression]
|
|
parseComplex text =
|
|
case T.uncons text of
|
|
Just ('(', rest) ->
|
|
case T.unsnoc rest of
|
|
Just (fields, ')') ->
|
|
traverse parseBooleanExpression (T.split (',' ==) fields)
|
|
_ -> Left "Unsupported Format"
|
|
_ -> Left "Unsupported Format"
|
|
|
|
parseField :: T.Text -> T.Text -> Either Error BooleanExpression
|
|
parseField field text =
|
|
case T.split ('.' ==) text of
|
|
["", "eq", v] -> Right (Eq (Name field) (Text v))
|
|
_ -> Left "Unsupported operation or format."
|
|
|
|
-----------------------------------
|
|
-- * Database
|
|
|
|
runSql :: Sqlite.Pool Sqlite.Database -> SQL -> IO [[Sqlite.SQLData]]
|
|
runSql pool (SQL sql values) = do
|
|
Sqlite.withPool pool $
|
|
Sqlite.runWith (fromString sql) (fmap fromValue values)
|
|
|
|
fromValue :: Value -> Sqlite.SQLData
|
|
fromValue = \case
|
|
Text s -> Sqlite.SQLText s
|