{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ViewPatterns #-} {-# OPTIONS -Wno-redundant-constraints #-} -- | SQLServer helpers. module Harness.Backend.Sqlserver ( livenessCheck, run_, defaultSourceMetadata, defaultSourceConfiguration, createTable, insertTable, trackTable, dropTable, untrackTable, setup, teardown, ) where import Control.Concurrent import Control.Monad.Reader import Data.Aeson (Value) import Data.Bool (bool) import Data.Foldable (for_) import Data.String import Data.Text (Text, pack, replace) import Data.Text qualified as T (pack, unpack, unwords) import Data.Text.Extended (commaSeparated) import Data.Time (defaultTimeLocale, formatTime) import Database.ODBC.SQLServer qualified as Sqlserver import Harness.Constants qualified as Constants import Harness.Exceptions import Harness.GraphqlEngine qualified as GraphqlEngine import Harness.Quoter.Yaml (yaml) import Harness.Test.Context (BackendType (SQLServer), defaultBackendTypeString, defaultSource) import Harness.Test.Schema (BackendScalarType (..), BackendScalarValue (..), ScalarValue (..)) import Harness.Test.Schema qualified as Schema import Harness.TestEnvironment (TestEnvironment) import Hasura.Prelude (tshow) import System.Process.Typed import Prelude -- | Check that the SQLServer service is live and ready to accept connections. livenessCheck :: HasCallStack => IO () livenessCheck = loop Constants.sqlserverLivenessCheckAttempts where loop 0 = error ("Liveness check failed for SQLServer.") loop attempts = catch ( bracket (Sqlserver.connect Constants.sqlserverConnectInfo) Sqlserver.close (const (pure ())) ) ( \(_failure :: ExitCodeException) -> do threadDelay Constants.sqlserverLivenessCheckIntervalMicroseconds loop (attempts - 1) ) -- | Run a plain SQL string against the server, ignore the -- result. Just checks for errors. run_ :: HasCallStack => String -> IO () run_ query' = catch ( bracket (Sqlserver.connect Constants.sqlserverConnectInfo) Sqlserver.close (\conn -> void (Sqlserver.exec conn (fromString query'))) ) ( \(e :: SomeException) -> error ( unlines [ "SQLServer query error:", show e, "SQL was:", query' ] ) ) -- | Metadata source information for the default MSSQL instance. defaultSourceMetadata :: Value defaultSourceMetadata = let source = defaultSource SQLServer backendType = defaultBackendTypeString SQLServer in [yaml| name: *source kind: *backendType tables: [] configuration: *defaultSourceConfiguration |] defaultSourceConfiguration :: Value defaultSourceConfiguration = [yaml| connection_info: database_url: *sqlserverConnectInfo pool_settings: {} |] where sqlserverConnectInfo = Constants.sqlserverConnectInfo -- | Serialize Table into a T-SQL statement, as needed, and execute it on the Sqlserver backend createTable :: Schema.Table -> IO () createTable Schema.Table {tableName, tableColumns, tablePrimaryKey = pk, tableReferences} = do run_ $ T.unpack $ T.unwords [ "CREATE TABLE", T.pack Constants.sqlserverDb <> "." <> tableName, "(", commaSeparated $ (mkColumn <$> tableColumns) <> (bool [mkPrimaryKey pk] [] (null pk)) <> (mkReference <$> tableReferences), ");" ] scalarType :: HasCallStack => Schema.ScalarType -> Text scalarType = \case Schema.TInt -> "INT" Schema.TStr -> "NVARCHAR(127)" Schema.TUTCTime -> "DATETIME" Schema.TBool -> "BOOLEAN" Schema.TCustomType txt -> Schema.getBackendScalarType txt bstMssql mkColumn :: Schema.Column -> Text mkColumn Schema.Column {columnName, columnType, columnNullable, columnDefault} = T.unwords [ wrapIdentifier columnName, scalarType columnType, bool "NOT NULL" "DEFAULT NULL" columnNullable, maybe "" ("DEFAULT " <>) columnDefault ] mkPrimaryKey :: [Text] -> Text mkPrimaryKey key = T.unwords [ "PRIMARY KEY", "(", commaSeparated $ map wrapIdentifier key, ")" ] mkReference :: Schema.Reference -> Text mkReference Schema.Reference {referenceLocalColumn, referenceTargetTable, referenceTargetColumn} = T.unwords [ "CONSTRAINT ", constraintName, "FOREIGN KEY ", "(", wrapIdentifier referenceLocalColumn, ")", "REFERENCES", T.pack Constants.sqlserverDb <> "." <> referenceTargetTable, "(", wrapIdentifier referenceTargetColumn, ")", "ON DELETE CASCADE", "ON UPDATE CASCADE" ] where constraintName :: Text constraintName = "FK_" <> referenceTargetTable <> "_" <> referenceTargetColumn <> "_" <> referenceLocalColumn -- | Serialize tableData into a T-SQL insert statement and execute it. insertTable :: HasCallStack => Schema.Table -> IO () insertTable Schema.Table {tableName, tableColumns, tableData} | null tableData = pure () | otherwise = do run_ $ T.unpack $ T.unwords [ "INSERT INTO", T.pack Constants.sqlserverDb <> "." <> wrapIdentifier tableName, "(", commaSeparated (wrapIdentifier . Schema.columnName <$> tableColumns), ")", "VALUES", commaSeparated $ mkRow <$> tableData, ";" ] -- | MSSQL identifiers which may contain spaces or be case-sensitive needs to be wrapped in @[]@. -- -- More information can be found in the mssql docs: -- https://docs.microsoft.com/en-us/sql/relational-databases/databases/database-identifiers wrapIdentifier :: Text -> Text wrapIdentifier identifier = "[" <> identifier <> "]" -- | 'ScalarValue' serializer for Mssql serialize :: ScalarValue -> Text serialize = \case VInt i -> tshow i VStr s -> "'" <> replace "'" "\'" s <> "'" VUTCTime t -> pack $ formatTime defaultTimeLocale "'%F %T'" t VBool b -> tshow @Int $ if b then 1 else 0 VNull -> "NULL" VCustomValue bsv -> Schema.formatBackendScalarValueType $ Schema.backendScalarValue bsv bsvMssql mkRow :: [Schema.ScalarValue] -> Text mkRow row = T.unwords [ "(", commaSeparated $ serialize <$> row, ")" ] -- | Serialize Table into a T-SQL DROP statement and execute it dropTable :: HasCallStack => Schema.Table -> IO () dropTable Schema.Table {tableName} = do run_ $ T.unpack $ T.unwords [ "DROP TABLE", -- we don't want @IF EXISTS@ here, because we don't want this to fail silently T.pack Constants.sqlserverDb <> "." <> tableName, ";" ] -- | Post an http request to start tracking the table trackTable :: HasCallStack => TestEnvironment -> Schema.Table -> IO () trackTable testEnvironment table = Schema.trackTable SQLServer (defaultSource SQLServer) table testEnvironment -- | Post an http request to stop tracking the table untrackTable :: HasCallStack => TestEnvironment -> Schema.Table -> IO () untrackTable testEnvironment table = Schema.untrackTable SQLServer (defaultSource SQLServer) table testEnvironment -- | Setup the schema in the most expected way. -- NOTE: Certain test modules may warrant having their own local version. setup :: HasCallStack => [Schema.Table] -> (TestEnvironment, ()) -> IO () setup tables (testEnvironment, _) = do -- Clear and reconfigure the metadata GraphqlEngine.setSource testEnvironment defaultSourceMetadata Nothing -- Setup and track tables for_ tables $ \table -> do createTable table insertTable table trackTable testEnvironment table -- Setup relationships for_ tables $ \table -> do Schema.trackObjectRelationships SQLServer table testEnvironment Schema.trackArrayRelationships SQLServer table testEnvironment -- | Teardown the schema and tracking in the most expected way. -- NOTE: Certain test modules may warrant having their own version. teardown :: HasCallStack => [Schema.Table] -> (TestEnvironment, ()) -> IO () teardown tables (testEnvironment, _) = do forFinally_ (reverse tables) $ \table -> finally (Schema.untrackRelationships SQLServer table testEnvironment) ( finally (untrackTable testEnvironment table) (dropTable table) )