haskell literest
This commit is contained in:
parent
da381fa32c
commit
94eb1fe55e
2 changed files with 271 additions and 0 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -2,3 +2,6 @@ c-moving-ball/raylib.h
|
||||||
c-moving-ball/wasm.sh
|
c-moving-ball/wasm.sh
|
||||||
c-moving-ball/wasm
|
c-moving-ball/wasm
|
||||||
c-moving-ball/game
|
c-moving-ball/game
|
||||||
|
|
||||||
|
hs-literest/literest.db
|
||||||
|
hs-literest/build/
|
||||||
|
|
268
hs-literest/literest.hs
Executable file
268
hs-literest/literest.hs
Executable file
|
@ -0,0 +1,268 @@
|
||||||
|
#!/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
|
Loading…
Add table
Reference in a new issue