|
| 1 | +# Typed SQL (typedSql) |
| 2 | + |
| 3 | +`typedSql` gives you compile-time checked SQL that integrates with IHP |
| 4 | +models. It is designed for cases where Query Builder is too limited and |
| 5 | +`sqlQuery` is too loose. The goal is to let you keep raw SQL while still |
| 6 | +getting: |
| 7 | + |
| 8 | +- compile-time validation (syntax and type checks) |
| 9 | +- automatic mapping to IHP's generated types |
| 10 | +- column name and nullability handling consistent with IHP |
| 11 | + |
| 12 | +This page explains how it works, how to use it, and how to debug it. |
| 13 | + |
| 14 | +## When to use typedSql |
| 15 | + |
| 16 | +Use `typedSql` when: |
| 17 | + |
| 18 | +- you need complex joins, CTEs, window functions, or vendor-specific SQL |
| 19 | +- you want a typed result without writing `FromRow` manually |
| 20 | +- you want compile-time SQL checking against your schema |
| 21 | + |
| 22 | +Keep using Query Builder for simple filtering and `sqlQuery` for truly |
| 23 | +runtime-generated SQL. |
| 24 | + |
| 25 | +## Quick start |
| 26 | + |
| 27 | +Enable Template Haskell and QuasiQuotes in a module where you want to use |
| 28 | +`typedSql`: |
| 29 | + |
| 30 | +```haskell |
| 31 | +{-# LANGUAGE QuasiQuotes #-} |
| 32 | +{-# LANGUAGE TemplateHaskell #-} |
| 33 | +{-# LANGUAGE DataKinds #-} |
| 34 | +``` |
| 35 | + |
| 36 | +Import the module and run the query using `sqlQueryTyped`: |
| 37 | + |
| 38 | +```haskell |
| 39 | +module Web.Controller.Users where |
| 40 | + |
| 41 | +import IHP.ControllerPrelude |
| 42 | +import IHP.TypedSql |
| 43 | + |
| 44 | +indexAction :: ?modelContext => IO () |
| 45 | +indexAction = do |
| 46 | + let userId = "00000000-0000-0000-0000-000000000001" :: Id User |
| 47 | + users <- sqlQueryTyped [typedSql| |
| 48 | + SELECT users.id, users.name |
| 49 | + FROM users |
| 50 | + WHERE users.id = ${userId} |
| 51 | + |] |
| 52 | + |
| 53 | + render Json { users } |
| 54 | +``` |
| 55 | + |
| 56 | +`typedSql` produces a `TypedQuery` value that is executed using |
| 57 | +`sqlQueryTyped`. If you expect a single row, pattern match on the result list. |
| 58 | + |
| 59 | +## Result type inference |
| 60 | + |
| 61 | +### Full table row |
| 62 | + |
| 63 | +If the query returns all columns of a table in the exact order defined in |
| 64 | +the schema (e.g. `SELECT users.*`), then the result is the generated |
| 65 | +record type: |
| 66 | + |
| 67 | +```haskell |
| 68 | +[user] <- sqlQueryTyped [typedSql| |
| 69 | + SELECT users.* FROM users WHERE users.id = ${userId} |
| 70 | +|] |
| 71 | +``` |
| 72 | + |
| 73 | +### Partial selection |
| 74 | + |
| 75 | +When you return a subset of columns, the result is a tuple: |
| 76 | + |
| 77 | +```haskell |
| 78 | +userInfo :: [(Id User, Text)] <- sqlQueryTyped [typedSql| |
| 79 | + SELECT users.id, users.name FROM users |
| 80 | +|] |
| 81 | +``` |
| 82 | + |
| 83 | +### Foreign keys |
| 84 | + |
| 85 | +If a column is a single-column foreign key, `typedSql` maps it to |
| 86 | +`Id' "other_table"` automatically: |
| 87 | + |
| 88 | +```haskell |
| 89 | +authorIds :: [Maybe (Id User)] <- sqlQueryTyped [typedSql| |
| 90 | + SELECT posts.author_id FROM posts WHERE posts.slug = ${slug} |
| 91 | +|] |
| 92 | +``` |
| 93 | + |
| 94 | +This follows IHP's usual `Id` mapping rules. |
| 95 | + |
| 96 | +## Parameter placeholders |
| 97 | + |
| 98 | +`typedSql` uses `${expr}` placeholders. Each placeholder becomes a `$N` |
| 99 | +parameter and is type-checked against the database. |
| 100 | + |
| 101 | +```haskell |
| 102 | +sqlQueryTyped [typedSql| |
| 103 | + SELECT * FROM posts WHERE posts.id = ${postId} |
| 104 | +|] |
| 105 | +``` |
| 106 | + |
| 107 | +Notes: |
| 108 | + |
| 109 | +- Do not use `?` or `$1` placeholders directly. |
| 110 | +- Parameter types come from OIDs by default, so UUID parameters are typed |
| 111 | + as `UUID`. Placeholders are coerced, so passing an `Id` works too. |
| 112 | +- When a placeholder is compared to a table-qualified column |
| 113 | + (e.g. `cv_setups.id = ${...}`) or to an unqualified column in a |
| 114 | + single-table query, `typedSql` treats the parameter as the column's |
| 115 | + Haskell type (including `Id` for PK/FK columns). This lets you pass |
| 116 | + `Id CvSetup` or `UUID`, and rejects `Id` values from other tables. |
| 117 | + Use explicit table/alias qualification for best results in joins. |
| 118 | +- Use explicit type annotations for ambiguous values: |
| 119 | + |
| 120 | +```haskell |
| 121 | +sqlQueryTyped [typedSql| |
| 122 | + SELECT * FROM posts WHERE posts.score > ${10 :: Int} |
| 123 | +|] |
| 124 | +``` |
| 125 | + |
| 126 | +- For arrays, prefer `= ANY(${ids})` rather than `IN (${ids})`. |
| 127 | + |
| 128 | +## Nullability rules |
| 129 | + |
| 130 | +`typedSql` tries to infer nullability from `pg_attribute` when a column is |
| 131 | +traceable to a table. If a column comes from an expression or a `LEFT |
| 132 | +JOIN`, the result is treated as nullable (`Maybe`) by default. |
| 133 | + |
| 134 | +If you want to force a non-null result, use SQL functions such as |
| 135 | +`COALESCE`: |
| 136 | + |
| 137 | +```haskell |
| 138 | +sqlQueryTyped [typedSql| |
| 139 | + SELECT COALESCE(posts.title, '') FROM posts |
| 140 | +|] |
| 141 | +``` |
| 142 | + |
| 143 | +## Type mapping |
| 144 | + |
| 145 | +The mapping follows IHP's conventions. Summary of common types: |
| 146 | + |
| 147 | +- `uuid` -> `UUID` (result columns from PK/FK map to `Id' "table"`) |
| 148 | +- `text`, `varchar`, `bpchar`, `citext` -> `Text` |
| 149 | +- `int2`, `int4` -> `Int` |
| 150 | +- `int8` -> `Integer` |
| 151 | +- `bool` -> `Bool` |
| 152 | +- `timestamptz` -> `UTCTime` |
| 153 | +- `timestamp` -> `LocalTime` |
| 154 | +- `date` -> `Day` |
| 155 | +- `time` -> `TimeOfDay` |
| 156 | +- `json`, `jsonb` -> `Aeson.Value` |
| 157 | +- `bytea` -> `Binary ByteString` |
| 158 | +- `numeric` -> `Scientific` |
| 159 | +- `interval` -> `PGInterval` |
| 160 | +- `point` -> `Point` |
| 161 | +- `polygon` -> `Polygon` |
| 162 | +- `inet` -> `IP` |
| 163 | +- `tsvector` -> `TSVector` |
| 164 | +- enums -> `<Enum>` (re-exported from `Generated.Types`) |
| 165 | +- composite types -> `<Type>` (re-exported from `Generated.Types`) |
| 166 | + |
| 167 | +Single-column composite selects (e.g. `SELECT my_table FROM my_table`) are |
| 168 | +not supported because `postgresql-simple` cannot decode composite fields |
| 169 | +into record types. Use `SELECT my_table.*` or list columns explicitly. |
| 170 | + |
| 171 | +If you have custom types, add a `FromField` instance and extend |
| 172 | +`hsTypeForPg` in `IHP.TypedSql`. |
| 173 | + |
| 174 | +## Runtime behavior |
| 175 | + |
| 176 | +`sqlQueryTyped` uses IHP's `ModelContext`, so it automatically: |
| 177 | + |
| 178 | +- uses the pooled connection |
| 179 | +- respects row-level security (RLS) |
| 180 | +- logs queries in the same format as `sqlQuery` |
| 181 | + |
| 182 | +There is no separate runtime connection layer. |
| 183 | + |
| 184 | +## How typedSql works internally |
| 185 | + |
| 186 | +`typedSql` is implemented as a Template Haskell quasiquoter. The pipeline |
| 187 | +looks like this: |
| 188 | + |
| 189 | +1. **Placeholder rewrite**: the SQL template is scanned for `${expr}` |
| 190 | + placeholders. Each placeholder is replaced by `$1`, `$2`, ... for the |
| 191 | + compile-time describe and by `?` for runtime execution. The captured |
| 192 | + expressions are parsed as Haskell AST. |
| 193 | +2. **Statement describe**: at compile time, `typedSql` prepares the query |
| 194 | + and runs `DESCRIBE` via libpq. This returns: |
| 195 | + - parameter OIDs (types for each `$N`) |
| 196 | + - result column OIDs, table OIDs, and attribute numbers |
| 197 | +3. **Catalog metadata fetch**: `typedSql` then queries `pg_catalog` to |
| 198 | + resolve: |
| 199 | + - table/column order (`pg_class`, `pg_attribute`) |
| 200 | + - nullability (`pg_attribute.attnotnull`) |
| 201 | + - primary/foreign keys (`pg_constraint`) |
| 202 | + - enum/composite/array metadata (`pg_type`) |
| 203 | +4. **IHP type mapping**: |
| 204 | + - Primary keys become `Id' "table"`. |
| 205 | + - Single-column foreign keys become `Id' "ref_table"`. |
| 206 | + - Enums map to `<Enum>` (re-exported from `Generated.Types`). |
| 207 | + - Composite types map to `<Type>` (re-exported from `Generated.Types`). |
| 208 | + - If the select list exactly matches `table.*` order, the result type |
| 209 | + becomes the generated record type (`<Model>` from `Generated.Types`). |
| 210 | +5. **TypedQuery generation**: the quasiquoter emits a `TypedQuery` value |
| 211 | + with: |
| 212 | + - `PG.Query` containing the rewritten SQL |
| 213 | + - `toField`-encoded parameters |
| 214 | + - a row parser (`field` for single column, `fromRow` for tuples) |
| 215 | + |
| 216 | +At runtime, `sqlQueryTyped` executes the generated query using IHP's |
| 217 | +`ModelContext`, so it reuses the same logging, RLS parameters, and |
| 218 | +connection pool as the rest of IHP. |
| 219 | + |
| 220 | +## Compile-time database access |
| 221 | + |
| 222 | +`typedSql` needs schema metadata at compile time. If |
| 223 | +`IHP_TYPED_SQL_BOOTSTRAP` is set, it uses bootstrap mode. Otherwise it |
| 224 | +connects to your live database. |
| 225 | + |
| 226 | +### Live database (default) |
| 227 | + |
| 228 | +`typedSql` connects to your database at compile time using `DATABASE_URL` |
| 229 | +or the same default used by IHP (`build/db`). Make sure the database is |
| 230 | +running and the schema is up to date when compiling. |
| 231 | + |
| 232 | +If the schema changes, recompile so the query description is refreshed. |
| 233 | + |
| 234 | +### Bootstrap mode (schema-only) |
| 235 | + |
| 236 | +Bootstrap mode avoids a running database by creating a temporary local |
| 237 | +Postgres instance from your SQL files at compile time. This keeps the |
| 238 | +SQL/type checking fully real while remaining reproducible in CI. |
| 239 | + |
| 240 | +Enable it with: |
| 241 | + |
| 242 | +```bash |
| 243 | +export IHP_TYPED_SQL_BOOTSTRAP=1 |
| 244 | +``` |
| 245 | + |
| 246 | +When enabled, `typedSql` will: |
| 247 | + |
| 248 | +1. Run `initdb` into a temp directory. |
| 249 | +2. Start a local `postgres` instance bound to a unix socket. |
| 250 | +3. Load `IHPSchema.sql` (if found), then `Application/Schema.sql`. |
| 251 | +4. Run the same describe + catalog queries against the temporary DB. |
| 252 | + |
| 253 | +Schema discovery rules: |
| 254 | + |
| 255 | +- `IHP_TYPED_SQL_SCHEMA` overrides the app schema path. |
| 256 | +- Otherwise, `Application/Schema.sql` is discovered by walking upward from |
| 257 | + the module containing the `[typedSql| ... |]`. |
| 258 | +- `IHP_TYPED_SQL_IHP_SCHEMA` overrides the IHP schema path. |
| 259 | +- Otherwise, if `IHP_LIB` is set, `IHP_LIB/IHPSchema.sql` is used. |
| 260 | +- Otherwise, it tries to locate `ihp-ide/data/IHPSchema.sql` when building |
| 261 | + from the IHP repo. |
| 262 | + |
| 263 | +Tools required on `PATH`: |
| 264 | + |
| 265 | +- `initdb` |
| 266 | +- `postgres` |
| 267 | +- `createdb` |
| 268 | +- `psql` |
| 269 | + |
| 270 | +If any tool is missing, `typedSql` will fail with a clear error. |
| 271 | + |
| 272 | +## Limitations and gotchas |
| 273 | + |
| 274 | +- Only `${expr}` placeholders are supported. |
| 275 | +- Queries with untracked parameters (e.g. `$1` without `${}`) will fail. |
| 276 | +- Multi-column foreign keys are not mapped to `Id` yet. |
| 277 | +- Nullability for computed columns defaults to `Maybe`. |
| 278 | +- Compile-time checks require a schema that matches the runtime schema. |
| 279 | + |
| 280 | +## Migration guidance |
| 281 | + |
| 282 | +If you currently use `sqlQuery` for complex queries: |
| 283 | + |
| 284 | +1. Wrap the query in `[typedSql| ... |]`. |
| 285 | +2. Replace `?` placeholders with `${expr}`. |
| 286 | +3. Replace custom `FromRow` with inferred tuples or records. |
| 287 | +4. Use `sqlQueryTyped` instead of `sqlQuery`. |
| 288 | + |
| 289 | +You get compile-time SQL validation with minimal changes. |
| 290 | + |
| 291 | +If you currently use `sqlExec` for statements: |
| 292 | + |
| 293 | +1. Wrap the statement in `[typedSql| ... |]`. |
| 294 | +2. Replace `?` placeholders with `${expr}`. |
| 295 | +3. Use `sqlExecTyped` instead of `sqlExec`. |
| 296 | + |
| 297 | +If you want rows back from an `INSERT`/`UPDATE`/`DELETE`, add a `RETURNING` |
| 298 | +clause and use `sqlQueryTyped`. |
| 299 | + |
| 300 | +## Troubleshooting |
| 301 | + |
| 302 | +**Error: could not connect to database** |
| 303 | + |
| 304 | +- Ensure `DATABASE_URL` is set and reachable during compilation. |
| 305 | +- Or set `IHP_TYPED_SQL_BOOTSTRAP=1` to use bootstrap mode. |
| 306 | + |
| 307 | +**Error: bootstrap requires 'initdb' in PATH** |
| 308 | + |
| 309 | +- Install the PostgreSQL client/server tools. |
| 310 | +- Make sure `initdb`, `postgres`, `createdb`, and `psql` are on `PATH`. |
| 311 | + |
| 312 | +**Error: could not find Application/Schema.sql** |
| 313 | + |
| 314 | +- Set `IHP_TYPED_SQL_SCHEMA` to an absolute path. |
| 315 | +- Or ensure the module using `typedSql` is inside your app directory. |
| 316 | + |
| 317 | +**Error: placeholder count mismatch** |
| 318 | + |
| 319 | +- Check that every parameter is written as `${expr}`. |
| 320 | + |
| 321 | +**Unexpected `Maybe` results** |
| 322 | + |
| 323 | +- The column is nullable or computed. Use `COALESCE` or accept `Maybe`. |
| 324 | + |
| 325 | +**Unknown type errors** |
| 326 | + |
| 327 | +- Add an explicit type cast in SQL or add a mapping in `IHP.TypedSql`. |
| 328 | + |
| 329 | +## API summary |
| 330 | + |
| 331 | +```haskell |
| 332 | +typedSql :: QuasiQuoter |
| 333 | +sqlQueryTyped :: (?modelContext :: ModelContext) => TypedQuery result -> IO [result] |
| 334 | +sqlExecTyped :: (?modelContext :: ModelContext) => TypedQuery result -> IO Int64 |
| 335 | +``` |
| 336 | + |
| 337 | +See `IHP.TypedSql` for the full implementation. |
0 commit comments