|
| 1 | +module IHP.TypedSql.Bootstrap |
| 2 | + ( describeUsingBootstrap |
| 3 | + ) where |
| 4 | + |
| 5 | +import Control.Exception (bracket_) |
| 6 | +import Control.Monad (when) |
| 7 | +import qualified Data.ByteString as BS |
| 8 | +import qualified Data.ByteString.Char8 as BS8 |
| 9 | +import Data.Maybe (catMaybes) |
| 10 | +import qualified Data.String.Conversions as CS |
| 11 | +import System.Directory (canonicalizePath, createDirectoryIfMissing, |
| 12 | + doesDirectoryExist, doesFileExist, |
| 13 | + findExecutable, removeDirectoryRecursive) |
| 14 | +import System.Environment (lookupEnv) |
| 15 | +import System.FilePath (isRelative, takeDirectory, takeFileName, (</>)) |
| 16 | +import System.IO (Handle, hIsEOF) |
| 17 | +import System.IO.Temp (withSystemTempDirectory) |
| 18 | +import qualified System.Process as Process |
| 19 | + |
| 20 | +import IHP.Prelude |
| 21 | +import IHP.TypedSql.Metadata (DescribeResult, describeStatementWith) |
| 22 | + |
| 23 | +data BootstrapConfig = BootstrapConfig |
| 24 | + { bcAppSchemaPath :: !FilePath |
| 25 | + , bcIhpSchemaPath :: !(Maybe FilePath) |
| 26 | + } |
| 27 | + |
| 28 | +data PgTools = PgTools |
| 29 | + { pgInitdb :: !FilePath |
| 30 | + , pgPostgres :: !FilePath |
| 31 | + , pgCreatedb :: !FilePath |
| 32 | + , pgPsql :: !FilePath |
| 33 | + } |
| 34 | + |
| 35 | +describeUsingBootstrap :: FilePath -> String -> IO DescribeResult |
| 36 | +describeUsingBootstrap sourcePath sqlText = do |
| 37 | + config <- resolveBootstrapConfig sourcePath |
| 38 | + withBootstrapDatabase config \dbUrl -> |
| 39 | + describeStatementWith dbUrl (CS.cs sqlText) |
| 40 | + |
| 41 | +resolveBootstrapConfig :: FilePath -> IO BootstrapConfig |
| 42 | +resolveBootstrapConfig sourcePath = do |
| 43 | + sourceDir <- canonicalizePath (takeDirectory sourcePath) |
| 44 | + appSchemaPath <- resolveSchemaPath sourceDir |
| 45 | + ihpSchemaPath <- resolveIhpSchemaPath sourceDir |
| 46 | + pure BootstrapConfig |
| 47 | + { bcAppSchemaPath = appSchemaPath |
| 48 | + , bcIhpSchemaPath = ihpSchemaPath |
| 49 | + } |
| 50 | + |
| 51 | +resolveSchemaPath :: FilePath -> IO FilePath |
| 52 | +resolveSchemaPath sourceDir = do |
| 53 | + envSchema <- lookupEnv "IHP_TYPED_SQL_SCHEMA" |
| 54 | + case envSchema of |
| 55 | + Just path -> resolveRelativePath sourceDir path >>= ensureFileExists "IHP_TYPED_SQL_SCHEMA" |
| 56 | + Nothing -> do |
| 57 | + findUpwards sourceDir ("Application" </> "Schema.sql") >>= \case |
| 58 | + Just found -> pure found |
| 59 | + Nothing -> |
| 60 | + fail "typedSql: could not find Application/Schema.sql. Set IHP_TYPED_SQL_SCHEMA to an absolute path." |
| 61 | + |
| 62 | +resolveIhpSchemaPath :: FilePath -> IO (Maybe FilePath) |
| 63 | +resolveIhpSchemaPath sourceDir = do |
| 64 | + envSchema <- lookupEnv "IHP_TYPED_SQL_IHP_SCHEMA" |
| 65 | + case envSchema of |
| 66 | + Just path -> Just <$> (resolveRelativePath sourceDir path >>= ensureFileExists "IHP_TYPED_SQL_IHP_SCHEMA") |
| 67 | + Nothing -> do |
| 68 | + envLib <- lookupEnv "IHP_LIB" |
| 69 | + fromLib <- case envLib of |
| 70 | + Just libPath -> do |
| 71 | + let candidate = libPath </> "IHPSchema.sql" |
| 72 | + exists <- doesFileExist candidate |
| 73 | + pure (if exists then Just candidate else Nothing) |
| 74 | + Nothing -> pure Nothing |
| 75 | + case fromLib of |
| 76 | + Just _ -> pure fromLib |
| 77 | + Nothing -> findUpwards sourceDir ("ihp-ide" </> "data" </> "IHPSchema.sql") |
| 78 | + |
| 79 | +resolveRelativePath :: FilePath -> FilePath -> IO FilePath |
| 80 | +resolveRelativePath baseDir path = do |
| 81 | + let resolved = if isRelative path then baseDir </> path else path |
| 82 | + canonicalizePath resolved |
| 83 | + |
| 84 | +ensureFileExists :: String -> FilePath -> IO FilePath |
| 85 | +ensureFileExists label path = do |
| 86 | + exists <- doesFileExist path |
| 87 | + if exists |
| 88 | + then pure path |
| 89 | + else fail ("typedSql: " <> label <> " points to missing file: " <> path) |
| 90 | + |
| 91 | +findUpwards :: FilePath -> FilePath -> IO (Maybe FilePath) |
| 92 | +findUpwards startDir relativePath = go startDir |
| 93 | + where |
| 94 | + go current = do |
| 95 | + let candidate = current </> relativePath |
| 96 | + exists <- doesFileExist candidate |
| 97 | + if exists |
| 98 | + then Just <$> canonicalizePath candidate |
| 99 | + else do |
| 100 | + let parent = takeDirectory current |
| 101 | + if parent == current |
| 102 | + then pure Nothing |
| 103 | + else go parent |
| 104 | + |
| 105 | +withBootstrapDatabase :: BootstrapConfig -> (BS.ByteString -> IO a) -> IO a |
| 106 | +withBootstrapDatabase BootstrapConfig { bcAppSchemaPath, bcIhpSchemaPath } action = do |
| 107 | + PgTools { pgInitdb, pgPostgres, pgCreatedb, pgPsql } <- resolvePgTools |
| 108 | + withSystemTempDirectory "ihp-typed-sql" \tempDir -> do |
| 109 | + let dataDir = tempDir </> "state" |
| 110 | + let socketDir = "/tmp" </> takeFileName tempDir |
| 111 | + let cleanupSocket = do |
| 112 | + exists <- doesDirectoryExist socketDir |
| 113 | + when exists (removeDirectoryRecursive socketDir) |
| 114 | + bracket_ (createDirectoryIfMissing True socketDir) cleanupSocket do |
| 115 | + Process.callProcess pgInitdb [dataDir, "--no-locale", "--encoding", "UTF8"] |
| 116 | + |
| 117 | + let params = |
| 118 | + (Process.proc pgPostgres ["-D", dataDir, "-k", socketDir, "-c", "listen_addresses="]) |
| 119 | + { Process.std_in = Process.CreatePipe |
| 120 | + , Process.std_out = Process.CreatePipe |
| 121 | + , Process.std_err = Process.CreatePipe |
| 122 | + } |
| 123 | + Process.withCreateProcess params \_ _ stderrHandle processHandle -> do |
| 124 | + errHandle <- maybe (fail "typedSql: unable to read postgres logs") pure stderrHandle |
| 125 | + let stop = do |
| 126 | + Process.terminateProcess processHandle |
| 127 | + _ <- Process.waitForProcess processHandle |
| 128 | + pure () |
| 129 | + let start = do |
| 130 | + waitUntilReady errHandle |
| 131 | + Process.callProcess pgCreatedb ["app", "-h", socketDir] |
| 132 | + let loadSchema file = Process.callProcess pgPsql ["-h", socketDir, "-d", "app", "-v", "ON_ERROR_STOP=1", "-f", file] |
| 133 | + forM_ (catMaybes [bcIhpSchemaPath, Just bcAppSchemaPath]) loadSchema |
| 134 | + bracket_ start stop do |
| 135 | + let dbUrl = CS.cs ("postgresql:///app?host=" <> socketDir) |
| 136 | + action dbUrl |
| 137 | + |
| 138 | +resolvePgTools :: IO PgTools |
| 139 | +resolvePgTools = do |
| 140 | + pgPostgres <- requireExecutable "postgres" |
| 141 | + let binDir = takeDirectory pgPostgres |
| 142 | + pgInitdb <- findInBinOrPath binDir "initdb" |
| 143 | + pgCreatedb <- findInBinOrPath binDir "createdb" |
| 144 | + pgPsql <- findInBinOrPath binDir "psql" |
| 145 | + pure PgTools { pgInitdb, pgPostgres, pgCreatedb, pgPsql } |
| 146 | + |
| 147 | +findInBinOrPath :: FilePath -> String -> IO FilePath |
| 148 | +findInBinOrPath binDir name = do |
| 149 | + let candidate = binDir </> name |
| 150 | + exists <- doesFileExist candidate |
| 151 | + if exists then pure candidate else requireExecutable name |
| 152 | + |
| 153 | +requireExecutable :: String -> IO FilePath |
| 154 | +requireExecutable name = |
| 155 | + findExecutable name >>= \case |
| 156 | + Just path -> pure path |
| 157 | + Nothing -> fail ("typedSql: bootstrap requires '" <> name <> "' in PATH") |
| 158 | + |
| 159 | +waitUntilReady :: Handle -> IO () |
| 160 | +waitUntilReady handle = do |
| 161 | + done <- hIsEOF handle |
| 162 | + if done |
| 163 | + then fail "typedSql: postgres exited before it was ready" |
| 164 | + else do |
| 165 | + line <- BS8.hGetLine handle |
| 166 | + if "database system is ready to accept connections" `BS8.isInfixOf` line |
| 167 | + then pure () |
| 168 | + else waitUntilReady handle |
0 commit comments