Skip to content

Commit 03a0029

Browse files
add typed sql
1 parent bf0b196 commit 03a0029

File tree

6 files changed

+1356
-0
lines changed

6 files changed

+1356
-0
lines changed

Guide/database.markdown

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ When dumping the database into the `Fixtures.sql` first and then rebuilding the
9696

9797
To have the full database dumped in a portable manner, you can do `make sql_dump > /tmp/my_app.sql`, which will generate a full SQL database dump, without owner or ACL information.
9898

99+
## Typed SQL
100+
101+
When Query Builder is not expressive enough and `sqlQuery` feels too loose,
102+
use `typedSql` for compile-time checked SQL with IHP type inference. It uses
103+
your schema to return `Id` and generated record types automatically.
104+
105+
See [Typed SQL](https://ihp.digitallyinduced.com/Guide/typed-sql.html).
106+
99107
## Haskell Bindings
100108

101109
### Model Context

Guide/layout.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
<a class="nav-link secondary" href="database.html">Basics</a>
8989
<a class="nav-link secondary" href="relationships.html">Relationships</a>
9090
<a class="nav-link secondary" href="querybuilder.html">Query Builder</a>
91+
<a class="nav-link secondary" href="typed-sql.html">Typed SQL</a>
9192
<a class="nav-link secondary" href="database-migrations.html">Migrations</a>
9293

9394

Guide/typed-sql.markdown

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
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.

ihp/IHP/Postgres/Typed.hs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{-# LANGUAGE ImplicitParams #-}
2+
3+
module IHP.Postgres.Typed
4+
( pgSQL
5+
, pgQuery
6+
) where
7+
8+
import IHP.ModelSupport (ModelContext)
9+
import IHP.Prelude
10+
import IHP.TypedSql (TypedQuery, sqlQueryTyped, typedSql)
11+
import Language.Haskell.TH.Quote (QuasiQuoter)
12+

0 commit comments

Comments
 (0)