gists/hs-literest/literest.hs
2025-04-11 09:02:27 +03:00

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