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

772 lines
24 KiB
Haskell

-- Unit and integration tests
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE NoMonomorphismRestriction #-}
{-# OPTIONS_GHC -Wno-missing-local-signatures #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
{-# HLINT ignore "Replace case with maybe" #-}
import Protolude (
Bool (False, True),
Either (Right),
FilePath,
IO,
Maybe (Just, Nothing),
($),
(&),
(<>),
)
import Protolude qualified as P
import Data.Aeson (Value (Number))
import Data.Aeson.KeyMap qualified as KeyMap
import Data.List qualified as List
import Data.Text qualified as T
import Database.SQLite.Simple (
Query,
execute_,
open,
)
import Database.SQLite.Simple qualified as SS
import Database.SQLite.Simple.QQ (sql)
import Servant.Server (runHandler)
import System.Directory (createDirectoryIfMissing)
import System.FilePath ((</>))
import System.Posix (changeWorkingDirectory, getWorkingDirectory)
import Test.Hspec (
SpecWith,
before_,
describe,
it,
pendingWith,
shouldBe,
shouldContain,
shouldMatchList,
)
import Test.Hspec qualified as Hspec
import AirGQL.Lib (
ColumnEntry (
ColumnEntry,
column_name,
column_name_gql,
datatype,
datatype_gql,
dflt_value,
isGenerated,
isOmittable,
isUnique,
notnull,
primary_key,
select_options
),
GqlTypeName (GqlTypeName, full, root),
ObjectType (Table),
SQLPost (SQLPost),
TableEntryRaw (
TableEntryRaw,
name,
object_type,
rootpage,
tbl_name
),
getColumns,
getTables,
replaceCaseInsensitive,
stringToGqlTypeName,
)
import AirGQL.Lib qualified
import AirGQL.Raw (raw)
import AirGQL.Servant.SqlQuery (sqlQueryPostHandler)
import AirGQL.Types.PragmaConf qualified as PragmaConf
import AirGQL.Types.SqlQueryPostResult (
SqlQueryPostResult (
affectedTables,
errors,
rows
),
)
import AirGQL.Types.Utils (encodeToText)
import AirGQL.Utils (
getOrderOfLinkedList,
removeIfExists,
withRetryConn,
)
import Tests.IntrospectionSpec qualified
import Tests.MutationSpec qualified
import Tests.QuerySpec qualified
import Tests.Utils (
dbPath,
fixtureDbId,
withDataDbConn,
withTestDbConn,
)
createUsersTableQuery :: Query
createUsersTableQuery =
[sql|
CREATE TABLE IF NOT EXISTS "users" (
"name" TEXT,
"email" TEXT NOT NULL UNIQUE,
"created_utc" TEXT NOT NULL,
"number_of_logins" INTEGER,
"progress" REAL,
PRIMARY KEY("email")
);
|]
createUsersTable :: SS.Connection -> IO ()
createUsersTable conn =
execute_ conn createUsersTableQuery
createSongsTableQuery :: Query
createSongsTableQuery =
[sql|
CREATE TABLE IF NOT EXISTS "songs" (
"name" TEXT NOT NULL,
"duration_seconds" INTEGER NOT NULL
);
|]
createSongsTable :: SS.Connection -> IO ()
createSongsTable conn =
execute_ conn createSongsTableQuery
-- prettyPrintSchema :: Schema m -> Text
-- prettyPrintSchema schema =
-- let
-- ObjectType name queryDesc interfaceTypes resolvers = query schema
-- in ""
-- <> "Description:\n" <> show (description schema) <> "\n\n"
-- <> "Query:\n\n"
-- <> " Name:\n" <> name <> "\n\n"
-- <> " Description:\n" <> show queryDesc <> "\n\n"
-- <> " InterfaceTypes:\n" <> show interfaceTypes <> "\n\n"
-- <> " Resolvers:\n" <> show resolvers <> "\n\n"
-- <> "Mutation:\n\n" <> show (mutation schema) <> "\n\n"
-- <> "Subscriptions:\n" <> show (subscription schema) <> "\n\n"
-- -- "Directives:\n" <> show (directives schema) <> "\n\n" <>
-- -- "Types:\n" <> show (types schema) <> "\n\n" <>
-- -- "Interface Implementations:\n" <> show (implementations schema) <> "\n\n"
-- <> ""
testSuite :: SpecWith ()
testSuite = do
describe "Utils" $ do
-- Hspec.fit "pretty prints the schema" $ do
-- conn <- open $ unpack dbPath
-- tables <- getTables conn
-- schema <-
-- getDerivedSchema defaultSchemaConf conn dbPath tables
-- putText $ prettyPrintSchema schema
-- True `shouldBe` True
it "replaces strings case insensitively" $ do
let
results =
[ replaceCaseInsensitive "hello" "hi" "hello world"
, replaceCaseInsensitive "hello" "hi" "Hello World"
, replaceCaseInsensitive "l" "L" "Hello World"
]
results `shouldBe` ["hi world", "hi World", "HeLLo WorLd"]
it "loads all tables from database" $ do
tables <- withRetryConn dbPath $ \conn ->
getTables conn
shouldBe
tables
[ TableEntryRaw
{ name = "users"
, tbl_name = "users"
, object_type = Table
, rootpage = 2
, AirGQL.Lib.sql =
"\
\CREATE TABLE \"users\" (\n\
\ \"name\" TEXT,\n\
\ \"email\" TEXT NOT NULL UNIQUE,\n\
\ \"created_utc\" TEXT NOT NULL,\n\
\ \"number_of_logins\" INTEGER,\n\
\ \"progress\" REAL,\n\
\ PRIMARY KEY(\"email\")\n\
\ )"
}
, TableEntryRaw
{ name = "songs"
, tbl_name = "songs"
, object_type = Table
, rootpage = 4
, AirGQL.Lib.sql =
"\
\CREATE TABLE \"songs\" (\n\
\ \"name\" TEXT NOT NULL,\n\
\ \"duration_seconds\" INTEGER NOT NULL\n\
\ )"
}
]
describe "getColumns" $ do
it "loads all columns from users table" $ do
tableColumns <- withRetryConn dbPath $ \conn ->
getColumns fixtureDbId conn "users"
let
columnsExpected =
[ ColumnEntry
{ column_name = "rowid"
, column_name_gql = "rowid"
, datatype = "INTEGER"
, datatype_gql = Just $ stringToGqlTypeName "Int"
, select_options = Nothing
, notnull = True
, isUnique = True
, isOmittable = True
, isGenerated = False
, isReference = False
, dflt_value = Nothing
, primary_key = True
, isRowid = True
}
, ColumnEntry
{ column_name = "name"
, column_name_gql = "name"
, datatype = "TEXT"
, datatype_gql = Just $ stringToGqlTypeName "String"
, select_options = Nothing
, notnull = False
, isUnique = False
, isOmittable = True
, isGenerated = False
, isReference = False
, dflt_value = Nothing
, primary_key = False
, isRowid = False
}
, ColumnEntry
{ column_name = "email"
, column_name_gql = "email"
, datatype = "TEXT"
, datatype_gql = Just $ stringToGqlTypeName "String"
, select_options = Nothing
, notnull = True
, isUnique = True
, isOmittable = False
, isGenerated = False
, isReference = False
, dflt_value = Nothing
, primary_key = True
, isRowid = False
}
, ColumnEntry
{ column_name = "created_utc"
, column_name_gql = "created_utc"
, datatype = "TEXT"
, datatype_gql = Just $ stringToGqlTypeName "String"
, select_options = Nothing
, notnull = True
, isUnique = False
, isOmittable = False
, isGenerated = False
, isReference = False
, dflt_value = Nothing
, primary_key = False
, isRowid = False
}
, ColumnEntry
{ column_name = "number_of_logins"
, column_name_gql = "number_of_logins"
, datatype = "INTEGER"
, datatype_gql = Just $ stringToGqlTypeName "Int"
, select_options = Nothing
, notnull = False
, isUnique = False
, isOmittable = True
, isGenerated = False
, isReference = False
, dflt_value = Nothing
, primary_key = False
, isRowid = False
}
, ColumnEntry
{ column_name = "progress"
, column_name_gql = "progress"
, datatype = "REAL"
, datatype_gql = Just $ stringToGqlTypeName "Float"
, select_options = Nothing
, notnull = False
, isUnique = False
, isOmittable = True
, isGenerated = False
, isReference = False
, dflt_value = Nothing
, primary_key = False
, isRowid = False
}
]
tableColumns `shouldBe` columnsExpected
it "loads a nullable single-select column" $ do
let dbName = "creates_nullable_single-select.db"
withTestDbConn dbName $ \conn -> do
execute_
conn
[sql|
CREATE TABLE checks (
color TEXT CHECK (color IN ('red', 'green', 'blue'))
)
|]
tableColumns <- getColumns dbName conn "checks"
let
columnExpected =
ColumnEntry
{ column_name = "color"
, column_name_gql = "color"
, datatype = "TEXT"
, datatype_gql =
Just $
GqlTypeName
{ full = "checks_color_String"
, root = "String"
}
, select_options = Just ["red", "green", "blue"]
, notnull = False
, isUnique = False
, isOmittable = True
, isGenerated = False
, isReference = False
, dflt_value = Nothing
, primary_key = False
, isRowid = False
}
P.lastMay tableColumns `shouldBe` Just columnExpected
it "loads a non-null single-select column" $ do
let dbName = "creates_non-null_single-select.db"
withTestDbConn dbName $ \conn -> do
execute_
conn
[sql|
CREATE TABLE checks (
color TEXT CHECK (color IN ('red', 'green', 'blue')) NOT NULL
)
|]
tableColumns <- getColumns dbName conn "checks"
let
columnExpected =
ColumnEntry
{ column_name = "color"
, column_name_gql = "color"
, datatype = "TEXT"
, datatype_gql =
Just $
GqlTypeName
{ full = "checks_color_String"
, root = "String"
}
, select_options = Just ["red", "green", "blue"]
, notnull = True
, isUnique = False
, isOmittable = False
, isGenerated = False
, isReference = False
, dflt_value = Nothing
, primary_key = False
, isRowid = False
}
P.lastMay tableColumns `shouldBe` Just columnExpected
it "coerces a multi-type single-select column to text" $ do
let dbName = "multi-type_single-select.db"
withTestDbConn dbName $ \conn -> do
execute_
conn
[sql|
CREATE TABLE checks (
value TEXT CHECK (value IN (1, 2.2, 'three'))
)
|]
tableColumns <- getColumns dbName conn "checks"
let
columnExpected =
ColumnEntry
{ column_name = "value"
, column_name_gql = "value"
, datatype = "TEXT"
, datatype_gql =
Just $
GqlTypeName
{ full = "checks_value_String"
, root = "String"
}
, select_options = Just ["1", "2.2", "three"]
, notnull = False
, isUnique = False
, isOmittable = True
, isGenerated = False
, isReference = False
, dflt_value = Nothing
, primary_key = False
, isRowid = False
}
P.lastMay tableColumns `shouldBe` Just columnExpected
it "marks integer primary keys as omittable" $ do
let dbName = "integer-omittable-primary-key.db"
withTestDbConn dbName $ \conn -> do
execute_
conn
[sql|
CREATE TABLE items (
id INTEGER PRIMARY KEY
)
|]
tableColumns <- getColumns dbName conn "items"
let
columnExpected =
ColumnEntry
{ column_name = "id"
, column_name_gql = "id"
, datatype = "INTEGER"
, datatype_gql = Just $ stringToGqlTypeName "Int"
, select_options = Nothing
, notnull = True
, isUnique = True
, isOmittable = True
, isGenerated = False
, isReference = False
, dflt_value = Nothing
, primary_key = True
, isRowid = False
}
P.lastMay tableColumns `shouldBe` Just columnExpected
it "correctly parses generated columns" $ do
let dbName = "generated-columns.db"
withTestDbConn dbName $ \conn -> do
execute_
conn
[sql|
CREATE TABLE "generated_columns" (
"start" REAL NOT NULL,
"end" REAL NOT NULL,
"distance_km" REAL GENERATED ALWAYS AS ("end" - "start") STORED,
"distance_miles" REAL GENERATED ALWAYS AS (distance_km * 1.6) VIRTUAL
)
|]
tableColumns <- getColumns dbName conn "generated_columns"
let (generated, non_generated) =
-- => [ColumnDef]
tableColumns
-- => ([ColumnDef], [ColumnDef])
& List.partition (\c -> c.isGenerated)
-- => (Text, Text)
& P.bimap
(P.fmap column_name)
(P.fmap column_name)
-- We use `shouldMatchList` because the order does not matter
non_generated `shouldMatchList` ["start", "end", "rowid"]
generated `shouldMatchList` ["distance_km", "distance_miles"]
it "sorts the tables from the metadata table" $ do
getOrderOfLinkedList [("a", Nothing), ("b", Just "a"), ("c", Just "b")]
`shouldBe` ["a", "b", "c"]
getOrderOfLinkedList [("a", Nothing), ("c", Just "b"), ("b", Just "a")]
`shouldBe` ["a", "b", "c"]
getOrderOfLinkedList [("b", Just "a"), ("c", Just "b"), ("a", Nothing)]
`shouldBe` ["a", "b", "c"]
getOrderOfLinkedList [("a", Just "x"), ("c", Just "b"), ("b", Just "a")]
`shouldBe` ["x", "a", "b", "c"]
getOrderOfLinkedList [("c", Just "b"), ("b", Just "a"), ("a", Just "x")]
`shouldBe` ["x", "a", "b", "c"]
getOrderOfLinkedList [("a", Nothing), ("b", Just "a"), ("c", Nothing)]
`shouldBe` ["a", "b", "c"]
getOrderOfLinkedList [("a", Nothing), ("b", Nothing), ("c", Nothing)]
`shouldBe` ["a", "b", "c"]
getOrderOfLinkedList [("a", Nothing), ("b", Just "a"), ("d", Just "c")]
`shouldBe` ["a", "b", "c", "d"]
-- Nothing must always be first
getOrderOfLinkedList [("c", Just "b"), ("d", Just "c"), ("a", Nothing)]
`shouldBe` ["a", "b", "c", "d"]
-- Should *not* error out on cycles
getOrderOfLinkedList [("c", Just "a"), ("b", Just "a"), ("a", Just "b")]
`shouldBe` ["a", "b", "c"]
describe "REST API" $ do
describe "SQL" $ do
it "supports retrieving the schema" $ do
-- let query = "CREATE TABLE users (name TEXT, email TEXT);"
-- let dbPost = DatabasePost "test-db" (Just "test-team") "" (Just query)
-- Right schemaResult <-
-- runHandler $
-- apiDatabaseSchemaGetHandler ctx "TODO"
-- T.strip schemaResult `shouldBe` query
pendingWith "Create database first"
it "supports executing an SQL query" $ do
let dbId = "api-sql-query"
withDataDbConn dbId $ \_ -> do
Right result <-
runHandler $
sqlQueryPostHandler
PragmaConf.defaultConf
("_TEST_" <> dbId)
SQLPost{query = "SELECT TRUE, 123, 'test'"}
result.rows
`shouldBe` [ KeyMap.fromList
[ ("TRUE", Number 1)
, ("123", Number 123)
, ("'test'", "test")
]
]
result.affectedTables `shouldMatchList` []
it "return columns in the requested order" $ do
-- Even though JSON objects are unordered by definition,
-- the fields (columns) must be returned in the requested order
-- as Elm relies on it for decoding.
let dbId = "api-sql-col-order"
withDataDbConn dbId $ \_ -> do
Right result <-
runHandler $
sqlQueryPostHandler
PragmaConf.defaultConf
("_TEST_" <> dbId)
SQLPost{query = "SELECT 0 AS b, 0 AS a"}
(result & encodeToText & T.unpack)
`shouldContain` "[{\"b\":0,\"a\":0}]"
result.affectedTables `shouldMatchList` []
it "supports using math functions" $ do
let dbId = "api-sql-math-query"
withDataDbConn dbId $ \_ -> do
Right result <-
runHandler $
sqlQueryPostHandler
PragmaConf.defaultConf
("_TEST_" <> dbId)
SQLPost{query = "SELECT abs(floor(cos(2 * pi() / 3)))"}
result.rows
`shouldBe` [ KeyMap.singleton
"abs(floor(cos(2 * pi() / 3)))"
(Number 1.0)
]
result.affectedTables `shouldMatchList` []
-- The following few tests verify that different operations return
-- the proper set of affected tables. This query creates a completely
-- unrelated table just to ensure it is not getting returned by the
-- methods tested below
let createUnrelatedTable = do
[sql|
CREATE TABLE IF NOT EXISTS todos (
title TEXT,
content TEXT,
done INTEGER
)
|]
-- This query creates a table commonly used by the following few tests.
let createNotesTable = do
[sql|
CREATE TABLE IF NOT EXISTS notes (
content TEXT
)
|]
it "should not allow rowid references" $ do
let dbId = "api-sql-rowid-references"
let query = "CREATE TABLE foo ( bar TEXT references goo(rowid) )"
withDataDbConn dbId $ \_ -> do
Right result <-
runHandler $
sqlQueryPostHandler
PragmaConf.defaultConf
("_TEST_" <> dbId)
SQLPost{query = query}
let
expectedMessage =
"Column 'bar' references the rowid column of table 'goo'.\n"
<> "This is not supported by SQLite:\n"
<> "https://www.sqlite.org/foreignkeys.html"
result.errors `shouldBe` [expectedMessage]
it "should error out on `without rowid` table creation" $ do
let dbId = "api-sql-without-rowid"
let query = "CREATE TABLE foo (bar INTEGER PRIMARY KEY) WITHOUT ROWID"
withDataDbConn dbId $ \_ -> do
Right result <-
runHandler $
sqlQueryPostHandler
PragmaConf.defaultConf
("_TEST_" <> dbId)
SQLPost{query = query}
let
expectedMessage =
"Table 'foo' does not have a rowid column. "
<> "Such tables are not currently supported by Airsequel."
result.errors `shouldBe` [expectedMessage]
it "should return no affected tables on a simple select" $ do
let dbId = "api-sql-simple-select"
withDataDbConn dbId $ \conn -> do
execute_ conn createUnrelatedTable
execute_ conn createNotesTable
execute_
conn
[sql|
INSERT INTO notes VALUES ("hello")
|]
Right result <-
runHandler $
sqlQueryPostHandler
PragmaConf.defaultConf
("_TEST_" <> dbId)
SQLPost{query = "SELECT * from notes"}
result.rows
`shouldBe` [KeyMap.singleton "content" "hello"]
result.affectedTables `shouldMatchList` []
it "should return the correct affected tables when creating tables" $ do
let dbId = "api-sql-simple-create"
withDataDbConn dbId $ \conn -> do
execute_ conn createUnrelatedTable
Right result <-
runHandler $
sqlQueryPostHandler
PragmaConf.defaultConf
("_TEST_" <> dbId)
SQLPost
{ query = SS.fromQuery createNotesTable
}
result.affectedTables
`shouldMatchList` ["notes"]
it "should return the correct affected tables when dropping tables" $ do
let dbId = "api-sql-simple-drop"
withDataDbConn dbId $ \conn -> do
execute_ conn createUnrelatedTable
execute_ conn createNotesTable
Right result <-
runHandler $
sqlQueryPostHandler
PragmaConf.defaultConf
("_TEST_" <> dbId)
SQLPost{query = "DROP TABLE notes"}
result.affectedTables `shouldMatchList` ["notes"]
it "should return the correct affected tables when editing tables" $ do
let dbId = "api-sql-add-column"
withDataDbConn dbId $ \conn -> do
execute_ conn createUnrelatedTable
execute_ conn createNotesTable
Right result <-
runHandler $
sqlQueryPostHandler
PragmaConf.defaultConf
("_TEST_" <> dbId)
SQLPost
{ query =
"ALTER TABLE notes ADD COLUMN title TEXT"
}
result.affectedTables `shouldMatchList` ["notes"]
it "should mark every table as affected when changes are detected" $ do
let dbId = "api-sql-insert-values"
withDataDbConn dbId $ \conn -> do
execute_ conn createUnrelatedTable
execute_ conn createNotesTable
Right result <-
runHandler $
sqlQueryPostHandler
PragmaConf.defaultConf
("_TEST_" <> dbId)
SQLPost
{ query =
[raw| INSERT INTO "notes" VALUES ("hello") |]
}
result.affectedTables `shouldMatchList` ["notes", "todos"]
describe "Query" Tests.QuerySpec.main
describe "Mutation" Tests.MutationSpec.main
describe "Introspection" Tests.IntrospectionSpec.main
deleteDbEntries :: FilePath -> IO ()
deleteDbEntries databasePath = do
conn <- open databasePath
execute_ conn "delete from users"
execute_ conn "delete from songs"
main :: IO ()
main = do
cwd <- getWorkingDirectory
createDirectoryIfMissing True (".." </> "data" </> "TEST" </> "data")
changeWorkingDirectory (cwd </> ".." </> "data" </> "TEST")
removeIfExists dbPath
withRetryConn dbPath $ \conn -> do
createUsersTable conn
createSongsTable conn
Hspec.hspec $ before_ (deleteDbEntries dbPath) testSuite