Skip to content

Commit 34901f5

Browse files
hasql migration
1 parent 9e21b0c commit 34901f5

File tree

12 files changed

+2756
-1
lines changed

12 files changed

+2756
-1
lines changed

ihp/IHP/TypedSql.hs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{-# LANGUAGE ImplicitParams #-}
2+
{-# LANGUAGE RecordWildCards #-}
3+
4+
module IHP.TypedSql
5+
( typedSql
6+
, TypedQuery (..)
7+
, sqlQueryTyped
8+
, sqlExecTyped
9+
) where
10+
11+
import qualified Hasql.Decoders as HasqlDecoders
12+
import qualified Hasql.DynamicStatements.Snippet as Snippet
13+
import IHP.ModelSupport (ModelContext, sqlQueryHasql, withHasqlOrPgSimple)
14+
import IHP.Prelude
15+
16+
import IHP.TypedSql.Quoter (typedSql)
17+
import IHP.TypedSql.Types (TypedQuery (..))
18+
19+
-- | Run a typed query and return all rows.
20+
-- High-level: executes the generated hasql snippet with its decoder.
21+
sqlQueryTyped :: (?modelContext :: ModelContext) => TypedQuery result -> IO [result]
22+
sqlQueryTyped TypedQuery { tqSnippet, tqResultDecoder } =
23+
runTypedSqlSession tqSnippet (HasqlDecoders.rowList tqResultDecoder)
24+
25+
-- | Run a typed statement (INSERT/UPDATE/DELETE) and return affected row count.
26+
-- High-level: executes the generated hasql snippet and decodes rows affected.
27+
sqlExecTyped :: (?modelContext :: ModelContext) => TypedQuery result -> IO Int64
28+
sqlExecTyped TypedQuery { tqSnippet } =
29+
runTypedSqlSession tqSnippet HasqlDecoders.rowsAffected
30+
31+
runTypedSqlSession :: (?modelContext :: ModelContext) => Snippet.Snippet -> HasqlDecoders.Result result -> IO result
32+
runTypedSqlSession snippet decoder =
33+
withHasqlOrPgSimple
34+
(\pool -> sqlQueryHasql pool snippet decoder)
35+
(fail "typedSql: requires hasql pool and does not support pg-simple transactions or RLS contexts")

ihp/IHP/TypedSql/Bootstrap.hs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
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+
-- | Resolved schema inputs for bootstrap mode.
24+
-- typedSql uses this to run a temporary DB from SQL schema files.
25+
data BootstrapConfig = BootstrapConfig
26+
{ bcAppSchemaPath :: !FilePath
27+
, bcIhpSchemaPath :: !(Maybe FilePath)
28+
}
29+
30+
-- | Paths to postgres tools needed for bootstrapping.
31+
-- These are resolved from PATH to keep bootstrap hermetic.
32+
data PgTools = PgTools
33+
{ pgInitdb :: !FilePath
34+
, pgPostgres :: !FilePath
35+
, pgCreatedb :: !FilePath
36+
, pgPsql :: !FilePath
37+
}
38+
39+
-- | Describe a query by bootstrapping a temporary database from schema files.
40+
-- This is used when IHP_TYPED_SQL_BOOTSTRAP is enabled.
41+
describeUsingBootstrap :: FilePath -> String -> IO DescribeResult
42+
describeUsingBootstrap sourcePath sqlText = do
43+
config <- resolveBootstrapConfig sourcePath
44+
withBootstrapDatabase config \dbUrl ->
45+
describeStatementWith dbUrl (CS.cs sqlText)
46+
47+
-- | Resolve schema paths relative to the source file that contains typedSql.
48+
resolveBootstrapConfig :: FilePath -> IO BootstrapConfig
49+
resolveBootstrapConfig sourcePath = do
50+
sourceDir <- canonicalizePath (takeDirectory sourcePath)
51+
appSchemaPath <- resolveSchemaPath sourceDir
52+
ihpSchemaPath <- resolveIhpSchemaPath sourceDir
53+
pure BootstrapConfig
54+
{ bcAppSchemaPath = appSchemaPath
55+
, bcIhpSchemaPath = ihpSchemaPath
56+
}
57+
58+
-- | Locate the application schema (Application/Schema.sql) for bootstrapping.
59+
resolveSchemaPath :: FilePath -> IO FilePath
60+
resolveSchemaPath sourceDir = do
61+
envSchema <- lookupEnv "IHP_TYPED_SQL_SCHEMA"
62+
case envSchema of
63+
Just path -> resolveRelativePath sourceDir path >>= ensureFileExists "IHP_TYPED_SQL_SCHEMA"
64+
Nothing -> do
65+
findUpwards sourceDir ("Application" </> "Schema.sql") >>= \case
66+
Just found -> pure found
67+
Nothing ->
68+
fail "typedSql: could not find Application/Schema.sql. Set IHP_TYPED_SQL_SCHEMA to an absolute path."
69+
70+
-- | Locate the IHP schema (IHPSchema.sql) for bootstrapping, if present.
71+
resolveIhpSchemaPath :: FilePath -> IO (Maybe FilePath)
72+
resolveIhpSchemaPath sourceDir = do
73+
envSchema <- lookupEnv "IHP_TYPED_SQL_IHP_SCHEMA"
74+
case envSchema of
75+
Just path -> Just <$> (resolveRelativePath sourceDir path >>= ensureFileExists "IHP_TYPED_SQL_IHP_SCHEMA")
76+
Nothing -> do
77+
envLib <- lookupEnv "IHP_LIB"
78+
fromLib <- case envLib of
79+
Just libPath -> do
80+
let candidate = libPath </> "IHPSchema.sql"
81+
exists <- doesFileExist candidate
82+
pure (if exists then Just candidate else Nothing)
83+
Nothing -> pure Nothing
84+
case fromLib of
85+
Just _ -> pure fromLib
86+
Nothing -> findUpwards sourceDir ("ihp-ide" </> "data" </> "IHPSchema.sql")
87+
88+
-- | Resolve a possibly relative schema path to an absolute path.
89+
resolveRelativePath :: FilePath -> FilePath -> IO FilePath
90+
resolveRelativePath baseDir path = do
91+
let resolved = if isRelative path then baseDir </> path else path
92+
canonicalizePath resolved
93+
94+
-- | Verify that a schema file exists; fail with a typedSql-specific message otherwise.
95+
ensureFileExists :: String -> FilePath -> IO FilePath
96+
ensureFileExists label path = do
97+
exists <- doesFileExist path
98+
if exists
99+
then pure path
100+
else fail ("typedSql: " <> label <> " points to missing file: " <> path)
101+
102+
-- | Search upwards for a schema file starting from the given directory.
103+
findUpwards :: FilePath -> FilePath -> IO (Maybe FilePath)
104+
findUpwards startDir relativePath = go startDir
105+
where
106+
go current = do
107+
let candidate = current </> relativePath
108+
exists <- doesFileExist candidate
109+
if exists
110+
then Just <$> canonicalizePath candidate
111+
else do
112+
let parent = takeDirectory current
113+
if parent == current
114+
then pure Nothing
115+
else go parent
116+
117+
-- | Start a temporary postgres, load schemas, and run a metadata action.
118+
withBootstrapDatabase :: BootstrapConfig -> (BS.ByteString -> IO a) -> IO a
119+
withBootstrapDatabase BootstrapConfig { bcAppSchemaPath, bcIhpSchemaPath } action = do
120+
PgTools { pgInitdb, pgPostgres, pgCreatedb, pgPsql } <- resolvePgTools
121+
withSystemTempDirectory "ihp-typed-sql" \tempDir -> do
122+
let dataDir = tempDir </> "state"
123+
let socketDir = "/tmp" </> takeFileName tempDir
124+
let cleanupSocket = do
125+
exists <- doesDirectoryExist socketDir
126+
when exists (removeDirectoryRecursive socketDir)
127+
bracket_ (createDirectoryIfMissing True socketDir) cleanupSocket do
128+
Process.callProcess pgInitdb [dataDir, "--no-locale", "--encoding", "UTF8"]
129+
130+
let params =
131+
(Process.proc pgPostgres ["-D", dataDir, "-k", socketDir, "-c", "listen_addresses="])
132+
{ Process.std_in = Process.CreatePipe
133+
, Process.std_out = Process.CreatePipe
134+
, Process.std_err = Process.CreatePipe
135+
}
136+
Process.withCreateProcess params \_ _ stderrHandle processHandle -> do
137+
errHandle <- maybe (fail "typedSql: unable to read postgres logs") pure stderrHandle
138+
let stop = do
139+
Process.terminateProcess processHandle
140+
_ <- Process.waitForProcess processHandle
141+
pure ()
142+
let start = do
143+
waitUntilReady errHandle
144+
Process.callProcess pgCreatedb ["app", "-h", socketDir]
145+
let loadSchema file = Process.callProcess pgPsql ["-h", socketDir, "-d", "app", "-v", "ON_ERROR_STOP=1", "-f", file]
146+
forM_ (catMaybes [bcIhpSchemaPath, Just bcAppSchemaPath]) loadSchema
147+
bracket_ start stop do
148+
let dbUrl = CS.cs ("postgresql:///app?host=" <> socketDir)
149+
action dbUrl
150+
151+
-- | Resolve postgres tool paths from PATH (or adjacent to postgres binary).
152+
resolvePgTools :: IO PgTools
153+
resolvePgTools = do
154+
pgPostgres <- requireExecutable "postgres"
155+
let binDir = takeDirectory pgPostgres
156+
pgInitdb <- findInBinOrPath binDir "initdb"
157+
pgCreatedb <- findInBinOrPath binDir "createdb"
158+
pgPsql <- findInBinOrPath binDir "psql"
159+
pure PgTools { pgInitdb, pgPostgres, pgCreatedb, pgPsql }
160+
161+
-- | Prefer a tool in the same bin dir as postgres, fallback to PATH.
162+
findInBinOrPath :: FilePath -> String -> IO FilePath
163+
findInBinOrPath binDir name = do
164+
let candidate = binDir </> name
165+
exists <- doesFileExist candidate
166+
if exists then pure candidate else requireExecutable name
167+
168+
-- | Require a tool to exist in PATH, otherwise fail with a bootstrap-specific error.
169+
requireExecutable :: String -> IO FilePath
170+
requireExecutable name =
171+
findExecutable name >>= \case
172+
Just path -> pure path
173+
Nothing -> fail ("typedSql: bootstrap requires '" <> name <> "' in PATH")
174+
175+
-- | Block until postgres reports readiness in its stderr log.
176+
waitUntilReady :: Handle -> IO ()
177+
waitUntilReady handle = do
178+
done <- hIsEOF handle
179+
if done
180+
then fail "typedSql: postgres exited before it was ready"
181+
else do
182+
line <- BS8.hGetLine handle
183+
if "database system is ready to accept connections" `BS8.isInfixOf` line
184+
then pure ()
185+
else waitUntilReady handle

0 commit comments

Comments
 (0)