effect-qb 0.12.3 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +134 -0
- package/README.md +372 -224
- package/dist/mysql.js +5037 -4962
- package/dist/postgres.js +4978 -4903
- package/package.json +8 -1
- package/src/internal/column-state.ts +9 -1
- package/src/internal/column.ts +30 -17
- package/src/internal/expression-ast.ts +11 -0
- package/src/internal/expression.ts +2 -0
- package/src/internal/query-factory.ts +50 -63
- package/src/internal/query.ts +16 -2
- package/src/internal/sql-expression-renderer.ts +33 -9
- package/src/internal/table-options.ts +2 -1
- package/src/internal/table.ts +4 -3
- package/src/mysql/column.ts +2 -1
- package/src/mysql/executor.ts +20 -17
- package/src/mysql/function/aggregate.ts +6 -0
- package/src/mysql/function/core.ts +4 -0
- package/src/mysql/function/index.ts +19 -0
- package/src/mysql/function/json.ts +4 -0
- package/src/mysql/function/string.ts +6 -0
- package/src/mysql/function/temporal.ts +103 -0
- package/src/mysql/function/window.ts +7 -0
- package/src/mysql/private/query.ts +13 -0
- package/src/mysql/query.ts +1 -26
- package/src/mysql.ts +2 -0
- package/src/postgres/column.ts +1 -1
- package/src/postgres/executor.ts +19 -17
- package/src/postgres/function/aggregate.ts +6 -0
- package/src/postgres/function/core.ts +4 -0
- package/src/postgres/function/index.ts +19 -0
- package/src/postgres/function/json.ts +4 -0
- package/src/postgres/function/string.ts +6 -0
- package/src/postgres/function/temporal.ts +107 -0
- package/src/postgres/function/window.ts +7 -0
- package/src/postgres/private/query.ts +13 -0
- package/src/postgres/query.ts +1 -26
- package/src/postgres.ts +2 -0
package/README.md
CHANGED
|
@@ -2,12 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
Type-safe SQL query construction for PostgreSQL and MySQL, with query plans that carry result shapes, nullability, dialect compatibility, and statement constraints in the type system.
|
|
4
4
|
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`effect-qb` builds immutable query plans and pushes the interesting parts of SQL into the type system:
|
|
8
|
+
|
|
9
|
+
- exact projection shapes
|
|
10
|
+
- nullability and predicate-driven narrowing
|
|
11
|
+
- join optionality
|
|
12
|
+
- aggregate and grouping validation
|
|
13
|
+
- dialect compatibility
|
|
14
|
+
- statement and execution result types
|
|
15
|
+
|
|
16
|
+
The main contract is compile-time. `Query.ResultRow<typeof plan>` is the logical row type after query analysis, while `Query.RuntimeResultRow<typeof plan>` describes the conservative runtime remap shape. At runtime, the library renders SQL, executes it, normalizes raw driver values, applies schema-backed transforms where they exist, and remaps aliased columns back into nested objects.
|
|
17
|
+
|
|
18
|
+
## Why effect-qb
|
|
19
|
+
|
|
20
|
+
Use `effect-qb` when you want SQL plans to carry more than column names:
|
|
21
|
+
|
|
22
|
+
- exact nested projection shapes
|
|
23
|
+
- nullability refinement from predicates
|
|
24
|
+
- join optionality that changes with query structure
|
|
25
|
+
- grouped-query validation before SQL is rendered
|
|
26
|
+
- dialect-locked plans, renderers, and executor error channels
|
|
27
|
+
|
|
28
|
+
It is a query-construction library, not an ORM. It does not manage migrations, model identities, or runtime row decoding.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
If you only want the typed DSL and SQL rendering:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bun add effect-qb
|
|
36
|
+
npm install effect-qb
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If you want to execute plans with the built-in Postgres executor:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
bun add effect-qb effect @effect/sql @effect/sql-pg
|
|
43
|
+
npm install effect-qb effect @effect/sql @effect/sql-pg
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If you want to execute plans with the built-in MySQL executor:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bun add effect-qb effect @effect/sql @effect/sql-mysql2
|
|
50
|
+
npm install effect-qb effect @effect/sql @effect/sql-mysql2
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The built-in executors require an ambient `@effect/sql` `SqlClient`. If your app already uses Effect and `@effect/sql`, you likely already have the extra runtime packages installed.
|
|
54
|
+
|
|
55
|
+
For local development in this repository:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
bun install
|
|
59
|
+
```
|
|
60
|
+
|
|
5
61
|
## Table of Contents
|
|
6
62
|
|
|
7
|
-
- [Overview](#overview)
|
|
8
|
-
- [Why effect-qb](#why-effect-qb)
|
|
9
|
-
- [Installation](#installation)
|
|
10
63
|
- [Choose An Entrypoint](#choose-an-entrypoint)
|
|
64
|
+
- [Postgres Function](#postgres-function)
|
|
11
65
|
- [Quick Start](#quick-start)
|
|
12
66
|
- [Execution Model](#execution-model)
|
|
13
67
|
- [Feature Map](#feature-map)
|
|
@@ -57,63 +111,53 @@ Type-safe SQL query construction for PostgreSQL and MySQL, with query plans that
|
|
|
57
111
|
- [Limitations](#limitations)
|
|
58
112
|
- [Contributing](#contributing)
|
|
59
113
|
|
|
60
|
-
##
|
|
61
|
-
|
|
62
|
-
`effect-qb` builds immutable query plans and pushes the interesting parts of SQL into the type system:
|
|
63
|
-
|
|
64
|
-
- exact projection shapes
|
|
65
|
-
- nullability and predicate-driven narrowing
|
|
66
|
-
- join optionality
|
|
67
|
-
- aggregate and grouping validation
|
|
68
|
-
- dialect compatibility
|
|
69
|
-
- statement and execution result types
|
|
114
|
+
## Choose An Entrypoint
|
|
70
115
|
|
|
71
|
-
|
|
116
|
+
Available entrypoints:
|
|
72
117
|
|
|
73
|
-
|
|
118
|
+
- `effect-qb/postgres`
|
|
119
|
+
- `effect-qb/mysql`
|
|
74
120
|
|
|
75
|
-
Use `effect-qb` when you want
|
|
121
|
+
Use `effect-qb/postgres` when you want explicit Postgres branding throughout the plan, renderer, executor, datatypes, and errors.
|
|
76
122
|
|
|
77
|
-
|
|
78
|
-
- nullability refinement from predicates
|
|
79
|
-
- join optionality that changes with query structure
|
|
80
|
-
- grouped-query validation before SQL is rendered
|
|
81
|
-
- dialect-locked plans, renderers, and executor error channels
|
|
123
|
+
That entrypoint also exposes `Postgres.Function` for typed SQL functions and JSON helpers.
|
|
82
124
|
|
|
83
|
-
|
|
125
|
+
Use `effect-qb/mysql` when you want the MySQL-specific DSL, renderer, executor, datatypes, and errors. It also exposes `Mysql.Function` for typed SQL functions and JSON helpers.
|
|
84
126
|
|
|
85
|
-
##
|
|
127
|
+
## Postgres Function
|
|
86
128
|
|
|
87
|
-
|
|
129
|
+
`effect-qb/postgres` exposes `Postgres.Function` for typed SQL expressions. The helpers are expressions, so they compose like other query values and keep their result types.
|
|
88
130
|
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
```
|
|
131
|
+
```ts
|
|
132
|
+
import { Column as C, Function as F, Query as Q, Table } from "effect-qb/postgres"
|
|
92
133
|
|
|
93
|
-
|
|
134
|
+
const users = Table.make("users", {
|
|
135
|
+
id: C.uuid().pipe(C.primaryKey),
|
|
136
|
+
email: C.text(),
|
|
137
|
+
bio: C.text().pipe(C.nullable)
|
|
138
|
+
})
|
|
94
139
|
|
|
95
|
-
|
|
96
|
-
|
|
140
|
+
const userSummary = Q.select({
|
|
141
|
+
email: F.lower(users.email),
|
|
142
|
+
bio: F.coalesce(users.bio, "anonymous"),
|
|
143
|
+
seenAt: F.currentTimestamp()
|
|
144
|
+
}).pipe(
|
|
145
|
+
Q.from(users)
|
|
146
|
+
)
|
|
97
147
|
```
|
|
98
148
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
Available entrypoints:
|
|
102
|
-
|
|
103
|
-
- `effect-qb`
|
|
104
|
-
- `effect-qb/postgres`
|
|
105
|
-
- `effect-qb/mysql`
|
|
106
|
-
|
|
107
|
-
Use `effect-qb` when Postgres defaults are acceptable and you want the shortest imports.
|
|
149
|
+
`Postgres.Function` currently covers:
|
|
108
150
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
151
|
+
- scalar helpers like `coalesce`, `lower`, `upper`, and `concat`
|
|
152
|
+
- aggregate helpers like `count`, `max`, and `min`
|
|
153
|
+
- window helpers like `over`, `rowNumber`, `rank`, and `denseRank`
|
|
154
|
+
- temporal helpers like `now`, `currentDate`, `currentTime`, `currentTimestamp`, `localTime`, and `localTimestamp`
|
|
155
|
+
- JSON helpers via `Function.json`
|
|
112
156
|
|
|
113
157
|
## Quick Start
|
|
114
158
|
|
|
115
159
|
```ts
|
|
116
|
-
import { Column as C, Query as Q, Renderer, Table } from "effect-qb"
|
|
160
|
+
import { Column as C, Function as F, Query as Q, Renderer, Table } from "effect-qb/postgres"
|
|
117
161
|
|
|
118
162
|
const users = Table.make("users", {
|
|
119
163
|
id: C.uuid().pipe(C.primaryKey),
|
|
@@ -129,7 +173,7 @@ const posts = Table.make("posts", {
|
|
|
129
173
|
const postsPerUser = Q.select({
|
|
130
174
|
userId: users.id,
|
|
131
175
|
email: users.email,
|
|
132
|
-
postCount:
|
|
176
|
+
postCount: F.count(posts.id)
|
|
133
177
|
}).pipe(
|
|
134
178
|
Q.from(users),
|
|
135
179
|
Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
|
|
@@ -157,18 +201,18 @@ The runtime model is intentionally small:
|
|
|
157
201
|
|
|
158
202
|
1. build a typed plan
|
|
159
203
|
2. render SQL plus bind params
|
|
160
|
-
3.
|
|
161
|
-
4.
|
|
204
|
+
3. normalize raw driver values into canonical `effect-qb` runtime values
|
|
205
|
+
4. apply propagated runtime schemas where they exist
|
|
206
|
+
5. remap flat aliases like `profile__email` back into nested objects
|
|
162
207
|
|
|
163
|
-
|
|
208
|
+
For the logical/static row split, see `ResultRow vs RuntimeResultRow` below.
|
|
164
209
|
|
|
165
210
|
```ts
|
|
166
211
|
import * as SqlClient from "@effect/sql/SqlClient"
|
|
167
212
|
import * as Effect from "effect/Effect"
|
|
168
|
-
import
|
|
213
|
+
import { Executor as PostgresExecutor } from "effect-qb/postgres"
|
|
169
214
|
|
|
170
|
-
const
|
|
171
|
-
const executor = Postgres.Executor.fromSqlClient(renderer)
|
|
215
|
+
const executor = PostgresExecutor.make()
|
|
172
216
|
|
|
173
217
|
const rowsEffect = executor.execute(postsPerUser)
|
|
174
218
|
|
|
@@ -177,7 +221,7 @@ const rows = Effect.runSync(
|
|
|
177
221
|
)
|
|
178
222
|
```
|
|
179
223
|
|
|
180
|
-
|
|
224
|
+
Schema-backed columns and preserved projections can enforce runtime transforms during execution. Arbitrary query plans still do not become one big derived query schema automatically.
|
|
181
225
|
|
|
182
226
|
## Feature Map
|
|
183
227
|
|
|
@@ -186,6 +230,7 @@ The rest of this README goes deeper, but the main surface area is:
|
|
|
186
230
|
- table builders with keys, indexes, nullability, defaults, and schema-backed JSON columns
|
|
187
231
|
- select plans with joins, CTEs, derived tables, `values(...)`, `unnest(...)`, subqueries, and set operators
|
|
188
232
|
- mutation plans for `insert`, `update`, `delete`, `returning`, and conflict handling
|
|
233
|
+
- typed SQL functions via `Postgres.Function` and `Mysql.Function`
|
|
189
234
|
- renderers and executors for Postgres and MySQL
|
|
190
235
|
- type-level checks for missing sources, grouped selections, dialect compatibility, and JSON mutation compatibility
|
|
191
236
|
|
|
@@ -193,65 +238,92 @@ Dialect-specific capabilities are called out later. Postgres currently has the w
|
|
|
193
238
|
|
|
194
239
|
## Effect Schema Integration
|
|
195
240
|
|
|
196
|
-
`effect-qb`
|
|
241
|
+
`effect-qb` uses `effect/Schema` as the runtime contract for columns and tables.
|
|
197
242
|
|
|
198
243
|
That integration shows up in three places:
|
|
199
244
|
|
|
200
|
-
-
|
|
201
|
-
-
|
|
202
|
-
-
|
|
245
|
+
- column definitions carry runtime schemas
|
|
246
|
+
- tables derive `select`, `insert`, and `update` schemas from those columns
|
|
247
|
+
- built-in executors apply schema-backed transforms when selected projections carry a runtime schema
|
|
203
248
|
|
|
204
|
-
|
|
249
|
+
The schema-aware column APIs are:
|
|
205
250
|
|
|
251
|
+
- built-in columns like `C.uuid()`, `C.text()`, `C.number()`, `C.date()`, and `C.timestamp()`
|
|
252
|
+
- `C.schema(schema)` to replace a column's runtime schema without changing its SQL type or key/default metadata
|
|
206
253
|
- `C.custom(schema, dbType)` for arbitrary non-JSON columns
|
|
207
254
|
- `C.json(schema)` for JSON columns
|
|
208
|
-
- `column.schema` when you need the underlying Effect Schema attached to a column definition
|
|
209
255
|
|
|
210
|
-
|
|
256
|
+
### Defaults And Generated Columns
|
|
257
|
+
|
|
258
|
+
`C.default(expr)` and `C.generated(expr)` only affect write-shape:
|
|
259
|
+
|
|
260
|
+
- `C.default(expr)` keeps a column selectable and updatable, but optional on insert because the database may fill it
|
|
261
|
+
- `C.generated(expr)` omits a column from insert and update because the database owns the value
|
|
262
|
+
- both helpers keep the expression around for DDL rendering
|
|
211
263
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
-
|
|
264
|
+
The important rule for `C.schema(...)` is that the schema must accept the column's current runtime output, not the raw driver value.
|
|
265
|
+
|
|
266
|
+
- `C.date()` produces a canonical `LocalDateString`, so `C.date().pipe(C.schema(Schema.DateFromString))` is valid
|
|
267
|
+
- `C.int().pipe(C.schema(Schema.DateFromString))` is rejected because the column runtime type is `number`, not `string`
|
|
215
268
|
|
|
216
269
|
Example:
|
|
217
270
|
|
|
218
271
|
```ts
|
|
219
272
|
import * as Schema from "effect/Schema"
|
|
220
|
-
import { Column as C, Table } from "effect-qb"
|
|
273
|
+
import { Column as C, Executor, Function as F, Query as Q, Table } from "effect-qb/postgres"
|
|
221
274
|
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
275
|
+
const users = Table.make("users", {
|
|
276
|
+
id: C.uuid().pipe(C.primaryKey, C.generated(Q.literal("generated-user-id"))),
|
|
277
|
+
happenedOn: C.date().pipe(C.schema(Schema.DateFromString)),
|
|
278
|
+
profile: C.json(Schema.Struct({
|
|
279
|
+
visits: Schema.NumberFromString
|
|
280
|
+
})),
|
|
281
|
+
createdAt: C.timestamp().pipe(C.default(F.localTimestamp()))
|
|
225
282
|
})
|
|
226
283
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
284
|
+
type UserSelect = Table.SelectOf<typeof users>
|
|
285
|
+
type UserInsert = Table.InsertOf<typeof users>
|
|
286
|
+
type UserUpdate = Table.UpdateOf<typeof users>
|
|
287
|
+
|
|
288
|
+
const decoded = Schema.decodeUnknownSync(users.schemas.select)({
|
|
289
|
+
id: "11111111-1111-1111-1111-111111111111",
|
|
290
|
+
happenedOn: "2026-03-20",
|
|
291
|
+
profile: {
|
|
292
|
+
visits: "42"
|
|
293
|
+
},
|
|
294
|
+
createdAt: "2026-03-20T10:00:00"
|
|
236
295
|
})
|
|
296
|
+
|
|
297
|
+
decoded.happenedOn
|
|
298
|
+
// Date
|
|
299
|
+
|
|
300
|
+
decoded.profile.visits
|
|
301
|
+
// number
|
|
302
|
+
|
|
303
|
+
const plan = Q.select({
|
|
304
|
+
happenedOn: users.happenedOn,
|
|
305
|
+
profile: users.profile
|
|
306
|
+
}).pipe(
|
|
307
|
+
Q.from(users)
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
const rowsEffect = Executor.make().execute(plan)
|
|
237
311
|
```
|
|
238
312
|
|
|
239
313
|
From that one definition you get:
|
|
240
314
|
|
|
241
|
-
- bound SQL columns like `users.
|
|
242
|
-
-
|
|
243
|
-
-
|
|
244
|
-
-
|
|
315
|
+
- bound SQL columns like `users.profile`
|
|
316
|
+
- derived table schemas: `users.schemas.select`, `users.schemas.insert`, `users.schemas.update`
|
|
317
|
+
- static helper types that line up with those schemas
|
|
318
|
+
- executor-side transforms for schema-backed projections
|
|
245
319
|
|
|
246
|
-
|
|
320
|
+
That last point matters. Built-in executors do not just remap aliases. They first normalize raw driver values into canonical `effect-qb` runtime values, then apply propagated schemas where they exist. So a selected `users.happenedOn` can become a `Date`, and a selected `users.profile` can decode `"42"` into `42` via `Schema.NumberFromString`.
|
|
247
321
|
|
|
248
|
-
|
|
249
|
-
type UserSelect = Table.SelectOf<typeof users>
|
|
250
|
-
type UserInsert = Table.InsertOf<typeof users>
|
|
251
|
-
type UserUpdate = Table.UpdateOf<typeof users>
|
|
252
|
-
```
|
|
322
|
+
The boundary is still important:
|
|
253
323
|
|
|
254
|
-
|
|
324
|
+
- table schemas are full `effect/Schema` values for table-shaped data
|
|
325
|
+
- schema-backed columns and preserved projections can enforce runtime transforms on reads
|
|
326
|
+
- arbitrary query plans do not automatically become one big derived query schema
|
|
255
327
|
|
|
256
328
|
## Core Concepts
|
|
257
329
|
|
|
@@ -263,10 +335,10 @@ Every table exposes derived Effect Schemas:
|
|
|
263
335
|
import * as Schema from "effect/Schema"
|
|
264
336
|
|
|
265
337
|
const users = Table.make("users", {
|
|
266
|
-
id: C.uuid().pipe(C.primaryKey, C.generated),
|
|
338
|
+
id: C.uuid().pipe(C.primaryKey, C.generated(Q.literal("generated-user-id"))),
|
|
267
339
|
email: C.text().pipe(C.unique),
|
|
268
340
|
bio: C.text().pipe(C.nullable),
|
|
269
|
-
createdAt: C.timestamp().pipe(C.
|
|
341
|
+
createdAt: C.timestamp().pipe(C.default(F.localTimestamp()))
|
|
270
342
|
})
|
|
271
343
|
|
|
272
344
|
Schema.isSchema(users.schemas.select)
|
|
@@ -288,7 +360,7 @@ Tables are typed sources, not loose name strings. Columns carry DB types, nullab
|
|
|
288
360
|
|
|
289
361
|
```ts
|
|
290
362
|
import * as Schema from "effect/Schema"
|
|
291
|
-
import { Column as C, Table } from "effect-qb"
|
|
363
|
+
import { Column as C, Table } from "effect-qb/postgres"
|
|
292
364
|
|
|
293
365
|
const users = Table.make("users", {
|
|
294
366
|
id: C.uuid().pipe(C.primaryKey),
|
|
@@ -311,6 +383,41 @@ const events = analytics.table("events", {
|
|
|
311
383
|
})
|
|
312
384
|
```
|
|
313
385
|
|
|
386
|
+
### Table Options
|
|
387
|
+
|
|
388
|
+
Table-level options live on the table definition itself. They are pipeable and render into DDL:
|
|
389
|
+
|
|
390
|
+
- `Table.primaryKey(...)` for table-level composite keys
|
|
391
|
+
- `Table.unique(...)` for table-level unique constraints
|
|
392
|
+
- `Table.index(...)` for table-level indexes
|
|
393
|
+
- `Table.foreignKey(...)` for table-level foreign keys
|
|
394
|
+
- `Table.check(...)` for expression-only check constraints
|
|
395
|
+
|
|
396
|
+
```ts
|
|
397
|
+
import { Column as C, Query as Q, Table } from "effect-qb/postgres"
|
|
398
|
+
|
|
399
|
+
const orgs = Table.make("orgs", {
|
|
400
|
+
id: C.uuid().pipe(C.primaryKey),
|
|
401
|
+
slug: C.text().pipe(C.unique)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
const membershipsBase = Table.make("memberships", {
|
|
405
|
+
id: C.uuid().pipe(C.primaryKey),
|
|
406
|
+
orgId: C.uuid(),
|
|
407
|
+
role: C.text(),
|
|
408
|
+
note: C.text().pipe(C.nullable)
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
const memberships = membershipsBase.pipe(
|
|
412
|
+
Table.foreignKey("orgId", () => orgs, "id"),
|
|
413
|
+
Table.unique(["orgId", "role"] as const),
|
|
414
|
+
Table.index(["role", "orgId"] as const),
|
|
415
|
+
Table.check("role_not_empty", Q.neq(membershipsBase.role, Q.literal("")))
|
|
416
|
+
)
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
The `check` helper must receive an expression. Raw SQL strings are not accepted.
|
|
420
|
+
|
|
314
421
|
### Plans, Not Strings
|
|
315
422
|
|
|
316
423
|
`effect-qb` does not build rows from ad hoc string fragments. It builds typed plans. Partial plans are allowed while assembling a query, but rendering and execution require a complete plan.
|
|
@@ -378,13 +485,13 @@ const docs = Table.make("docs", {
|
|
|
378
485
|
}))
|
|
379
486
|
})
|
|
380
487
|
|
|
381
|
-
const cityPath =
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
488
|
+
const cityPath = F.json.path(
|
|
489
|
+
F.json.key("profile"),
|
|
490
|
+
F.json.key("address"),
|
|
491
|
+
F.json.key("city")
|
|
385
492
|
)
|
|
386
493
|
|
|
387
|
-
const city =
|
|
494
|
+
const city = F.json.get(docs.payload, cityPath)
|
|
388
495
|
type City = Q.OutputOfExpression<typeof city, {
|
|
389
496
|
readonly docs: {
|
|
390
497
|
readonly name: "docs"
|
|
@@ -396,17 +503,11 @@ type City = Q.OutputOfExpression<typeof city, {
|
|
|
396
503
|
|
|
397
504
|
### Dialect-specific Entrypoints
|
|
398
505
|
|
|
399
|
-
The root entrypoint defaults to the Postgres-flavored `Query` and `Table` DSLs:
|
|
400
|
-
|
|
401
|
-
```ts
|
|
402
|
-
import { Query as Q, Table } from "effect-qb"
|
|
403
|
-
```
|
|
404
|
-
|
|
405
506
|
Dialect entrypoints expose dialect-specific builders:
|
|
406
507
|
|
|
407
508
|
```ts
|
|
408
|
-
import
|
|
409
|
-
import
|
|
509
|
+
import { Query as PostgresQuery } from "effect-qb/postgres"
|
|
510
|
+
import { Query as MysqlQuery } from "effect-qb/mysql"
|
|
410
511
|
```
|
|
411
512
|
|
|
412
513
|
This matters for:
|
|
@@ -450,16 +551,14 @@ Projection typing is local. You usually do not need to define row interfaces you
|
|
|
450
551
|
`from(...)` and joins make referenced sources available to the plan. Derived tables, CTEs, and correlated sources stay typed.
|
|
451
552
|
|
|
452
553
|
```ts
|
|
453
|
-
const activePosts = Q.
|
|
454
|
-
Q.select({
|
|
554
|
+
const activePosts = Q.select({
|
|
455
555
|
userId: posts.userId,
|
|
456
556
|
title: posts.title
|
|
457
557
|
}).pipe(
|
|
458
558
|
Q.from(posts),
|
|
459
|
-
Q.where(Q.isNotNull(posts.title))
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
)
|
|
559
|
+
Q.where(Q.isNotNull(posts.title)),
|
|
560
|
+
Q.as("active_posts")
|
|
561
|
+
)
|
|
463
562
|
|
|
464
563
|
const usersWithPosts = Q.select({
|
|
465
564
|
userId: users.id,
|
|
@@ -478,9 +577,9 @@ type UsersWithPostsRow = Q.ResultRow<typeof usersWithPosts>
|
|
|
478
577
|
|
|
479
578
|
The same source story applies to:
|
|
480
579
|
|
|
481
|
-
- `Q.with(
|
|
482
|
-
- `Q.withRecursive(
|
|
483
|
-
- `Q.lateral(
|
|
580
|
+
- `subquery.pipe(Q.with("alias"))`
|
|
581
|
+
- `subquery.pipe(Q.withRecursive("alias"))`
|
|
582
|
+
- `subquery.pipe(Q.lateral("alias"))`
|
|
484
583
|
- `Q.values(...)`
|
|
485
584
|
- `Q.unnest(...)`
|
|
486
585
|
|
|
@@ -491,7 +590,7 @@ Predicates do more than render SQL. They can narrow result types.
|
|
|
491
590
|
```ts
|
|
492
591
|
const allPosts = Q.select({
|
|
493
592
|
title: posts.title,
|
|
494
|
-
upperTitle:
|
|
593
|
+
upperTitle: F.upper(posts.title)
|
|
495
594
|
}).pipe(
|
|
496
595
|
Q.from(posts)
|
|
497
596
|
)
|
|
@@ -504,7 +603,7 @@ type AllPostsRow = Q.ResultRow<typeof allPosts>
|
|
|
504
603
|
|
|
505
604
|
const titledPosts = Q.select({
|
|
506
605
|
title: posts.title,
|
|
507
|
-
upperTitle:
|
|
606
|
+
upperTitle: F.upper(posts.title)
|
|
508
607
|
}).pipe(
|
|
509
608
|
Q.from(posts),
|
|
510
609
|
Q.where(Q.isNotNull(posts.title))
|
|
@@ -530,6 +629,7 @@ The expression surface is large, but the important point is that result-shaping
|
|
|
530
629
|
|
|
531
630
|
```ts
|
|
532
631
|
import * as Schema from "effect/Schema"
|
|
632
|
+
import { Column as C, Function as F, Query as Q, Table } from "effect-qb/postgres"
|
|
533
633
|
|
|
534
634
|
const docs = Table.make("docs", {
|
|
535
635
|
id: C.uuid().pipe(C.primaryKey),
|
|
@@ -542,17 +642,17 @@ const docs = Table.make("docs", {
|
|
|
542
642
|
}))
|
|
543
643
|
})
|
|
544
644
|
|
|
545
|
-
const cityPath =
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
645
|
+
const cityPath = F.json.path(
|
|
646
|
+
F.json.key("profile"),
|
|
647
|
+
F.json.key("address"),
|
|
648
|
+
F.json.key("city")
|
|
549
649
|
)
|
|
550
650
|
|
|
551
651
|
const shapedDocs = Q.select({
|
|
552
652
|
title: Q.case()
|
|
553
653
|
.when(Q.isNull(posts.title), "missing")
|
|
554
|
-
.else(
|
|
555
|
-
profileCity:
|
|
654
|
+
.else(F.upper(posts.title)),
|
|
655
|
+
profileCity: F.json.text(docs.payload, cityPath),
|
|
556
656
|
titleAsText: Q.cast(posts.title, Q.type.text())
|
|
557
657
|
}).pipe(
|
|
558
658
|
Q.from(posts),
|
|
@@ -562,12 +662,12 @@ const shapedDocs = Q.select({
|
|
|
562
662
|
|
|
563
663
|
The same JSON path object can be reused across:
|
|
564
664
|
|
|
565
|
-
- `
|
|
566
|
-
- `
|
|
567
|
-
- `
|
|
568
|
-
- `
|
|
569
|
-
- `
|
|
570
|
-
- `
|
|
665
|
+
- `Function.json.get(...)`
|
|
666
|
+
- `Function.json.text(...)`
|
|
667
|
+
- `Function.json.set(...)`
|
|
668
|
+
- `Function.json.insert(...)`
|
|
669
|
+
- `Function.json.delete(...)`
|
|
670
|
+
- `Function.json.pathExists(...)`
|
|
571
671
|
|
|
572
672
|
Comparison and cast safety are dialect-aware. Incompatible operands are rejected unless you make the conversion explicit with `Q.cast(...)`.
|
|
573
673
|
|
|
@@ -576,13 +676,26 @@ Comparison and cast safety are dialect-aware. Incompatible operands are rejected
|
|
|
576
676
|
Grouped queries are checked structurally, not just by source provenance.
|
|
577
677
|
|
|
578
678
|
```ts
|
|
679
|
+
const invalidPostsPerUser = Q.select({
|
|
680
|
+
userId: users.id,
|
|
681
|
+
title: posts.title,
|
|
682
|
+
postCount: F.count(posts.id)
|
|
683
|
+
}).pipe(
|
|
684
|
+
Q.from(users),
|
|
685
|
+
Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
|
|
686
|
+
Q.groupBy(users.id)
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
type InvalidPostsPerUser = Q.CompletePlan<typeof invalidPostsPerUser>
|
|
690
|
+
// {
|
|
691
|
+
// __effect_qb_error__: "effect-qb: invalid grouped selection"
|
|
692
|
+
// __effect_qb_hint__:
|
|
693
|
+
// "Scalar selections must be covered by groupBy(...) when aggregates are present"
|
|
694
|
+
// }
|
|
695
|
+
|
|
579
696
|
const postsPerUser = Q.select({
|
|
580
697
|
userId: users.id,
|
|
581
|
-
postCount:
|
|
582
|
-
rowNumber: Q.over(Q.rowNumber(), {
|
|
583
|
-
partitionBy: [users.id],
|
|
584
|
-
orderBy: [{ value: users.id, direction: "asc" }]
|
|
585
|
-
})
|
|
698
|
+
postCount: F.count(posts.id)
|
|
586
699
|
}).pipe(
|
|
587
700
|
Q.from(users),
|
|
588
701
|
Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
|
|
@@ -593,11 +706,10 @@ type PostsPerUserRow = Q.ResultRow<typeof postsPerUser>
|
|
|
593
706
|
// {
|
|
594
707
|
// userId: string
|
|
595
708
|
// postCount: number
|
|
596
|
-
// rowNumber: number
|
|
597
709
|
// }
|
|
598
710
|
```
|
|
599
711
|
|
|
600
|
-
|
|
712
|
+
This catches invalid grouped queries before rendering, then the fixed plan keeps only grouped or aggregate selections.
|
|
601
713
|
|
|
602
714
|
### Combining Queries
|
|
603
715
|
|
|
@@ -654,15 +766,15 @@ const recentUsers = Q.select({
|
|
|
654
766
|
Postgres-only `distinct on` is available from the Postgres entrypoint:
|
|
655
767
|
|
|
656
768
|
```ts
|
|
657
|
-
import
|
|
769
|
+
import { Query as Q } from "effect-qb/postgres"
|
|
658
770
|
|
|
659
|
-
const recentEmails =
|
|
771
|
+
const recentEmails = Q.select({
|
|
660
772
|
id: users.id,
|
|
661
773
|
email: users.email
|
|
662
774
|
}).pipe(
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
775
|
+
Q.from(users),
|
|
776
|
+
Q.distinctOn(users.email),
|
|
777
|
+
Q.orderBy(users.email)
|
|
666
778
|
)
|
|
667
779
|
```
|
|
668
780
|
|
|
@@ -679,28 +791,38 @@ const insertUser = Q.insert(users, {
|
|
|
679
791
|
})
|
|
680
792
|
```
|
|
681
793
|
|
|
794
|
+
If every writable column is optional because of `C.default(...)`, `C.generated(...)`, or nullability, `Q.insert(table)` is the default-only form.
|
|
795
|
+
|
|
682
796
|
Composable sources are available when the input rows come from elsewhere:
|
|
683
797
|
|
|
684
798
|
```ts
|
|
685
799
|
const pendingUsers = Q.values([
|
|
686
800
|
{ id: "user-1", email: "alice@example.com" },
|
|
687
801
|
{ id: "user-2", email: "bob@example.com" }
|
|
688
|
-
]
|
|
802
|
+
]).pipe(
|
|
803
|
+
Q.as("pending_users")
|
|
804
|
+
)
|
|
689
805
|
|
|
690
|
-
const insertMany = Q.
|
|
806
|
+
const insertMany = Q.insert(users).pipe(
|
|
807
|
+
Q.from(pendingUsers)
|
|
808
|
+
)
|
|
691
809
|
```
|
|
692
810
|
|
|
693
|
-
`
|
|
811
|
+
`from(...)` also accepts `select(...)`, `unnest(...)`, and other compatible sources.
|
|
694
812
|
|
|
695
813
|
### Update
|
|
696
814
|
|
|
697
|
-
Updates stay expression-aware and can use
|
|
815
|
+
Updates stay expression-aware and can use `from(...)` sources where the dialect supports it.
|
|
698
816
|
|
|
699
817
|
```ts
|
|
700
|
-
const updateUsers = Q.
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
818
|
+
const updateUsers = Q.update(users, {
|
|
819
|
+
email: "author@example.com"
|
|
820
|
+
}).pipe(
|
|
821
|
+
Q.from(posts),
|
|
822
|
+
Q.where(Q.and(
|
|
823
|
+
Q.eq(posts.userId, users.id),
|
|
824
|
+
Q.eq(posts.title, "hello")
|
|
825
|
+
))
|
|
704
826
|
)
|
|
705
827
|
```
|
|
706
828
|
|
|
@@ -721,19 +843,25 @@ const deleteUser = Q.delete(users).pipe(
|
|
|
721
843
|
Conflict handling is modeled as a composable modifier instead of a string escape hatch.
|
|
722
844
|
|
|
723
845
|
```ts
|
|
724
|
-
const insertOrIgnore = Q.
|
|
725
|
-
action: "doNothing"
|
|
726
|
-
})(Q.insert(users, {
|
|
846
|
+
const insertOrIgnore = Q.insert(users, {
|
|
727
847
|
id: "user-1",
|
|
728
848
|
email: "alice@example.com"
|
|
729
|
-
})
|
|
849
|
+
}).pipe(
|
|
850
|
+
Q.onConflict(["id"] as const, {
|
|
851
|
+
action: "doNothing"
|
|
852
|
+
})
|
|
853
|
+
)
|
|
730
854
|
|
|
731
|
-
const upsertUser = Q.
|
|
855
|
+
const upsertUser = Q.insert(users, {
|
|
732
856
|
id: "user-1",
|
|
733
857
|
email: "alice@example.com"
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
|
|
858
|
+
}).pipe(
|
|
859
|
+
Q.onConflict(["id"] as const, {
|
|
860
|
+
update: {
|
|
861
|
+
email: Q.excluded(users.email)
|
|
862
|
+
}
|
|
863
|
+
})
|
|
864
|
+
)
|
|
737
865
|
```
|
|
738
866
|
|
|
739
867
|
Conflict targets are checked against the target table.
|
|
@@ -743,13 +871,15 @@ Conflict targets are checked against the target table.
|
|
|
743
871
|
Mutation plans can project typed rows with `returning(...)`.
|
|
744
872
|
|
|
745
873
|
```ts
|
|
746
|
-
const insertedUser = Q.
|
|
747
|
-
id: users.id,
|
|
748
|
-
email: users.email
|
|
749
|
-
})(Q.insert(users, {
|
|
874
|
+
const insertedUser = Q.insert(users, {
|
|
750
875
|
id: "user-1",
|
|
751
876
|
email: "alice@example.com"
|
|
752
|
-
})
|
|
877
|
+
}).pipe(
|
|
878
|
+
Q.returning({
|
|
879
|
+
id: users.id,
|
|
880
|
+
email: users.email
|
|
881
|
+
})
|
|
882
|
+
)
|
|
753
883
|
|
|
754
884
|
type InsertedUserRow = Q.ResultRow<typeof insertedUser>
|
|
755
885
|
// {
|
|
@@ -763,15 +893,15 @@ type InsertedUserRow = Q.ResultRow<typeof insertedUser>
|
|
|
763
893
|
Write plans can feed later reads in the same statement:
|
|
764
894
|
|
|
765
895
|
```ts
|
|
766
|
-
const insertedUsers = Q.
|
|
896
|
+
const insertedUsers = Q.insert(users, {
|
|
897
|
+
id: "user-1",
|
|
898
|
+
email: "alice@example.com"
|
|
899
|
+
}).pipe(
|
|
767
900
|
Q.returning({
|
|
768
901
|
id: users.id,
|
|
769
902
|
email: users.email
|
|
770
|
-
})
|
|
771
|
-
|
|
772
|
-
email: "alice@example.com"
|
|
773
|
-
})),
|
|
774
|
-
"inserted_users"
|
|
903
|
+
}),
|
|
904
|
+
Q.with("inserted_users")
|
|
775
905
|
)
|
|
776
906
|
|
|
777
907
|
const insertedUsersPlan = Q.select({
|
|
@@ -789,9 +919,9 @@ This is one of the places where the capability model matters: write-bearing nest
|
|
|
789
919
|
### Renderer
|
|
790
920
|
|
|
791
921
|
```ts
|
|
792
|
-
import
|
|
922
|
+
import { Renderer as PostgresRenderer } from "effect-qb/postgres"
|
|
793
923
|
|
|
794
|
-
const rendered =
|
|
924
|
+
const rendered = PostgresRenderer.make().render(postsPerUser)
|
|
795
925
|
|
|
796
926
|
rendered.sql
|
|
797
927
|
rendered.params
|
|
@@ -810,24 +940,26 @@ They do not carry a query-result schema.
|
|
|
810
940
|
### Executor
|
|
811
941
|
|
|
812
942
|
```ts
|
|
813
|
-
import
|
|
943
|
+
import { Executor as PostgresExecutor, Query as Q } from "effect-qb/postgres"
|
|
814
944
|
|
|
815
|
-
const
|
|
816
|
-
const executor = Postgres.Executor.fromSqlClient(renderer)
|
|
945
|
+
const executor = PostgresExecutor.make()
|
|
817
946
|
|
|
818
947
|
const rowsEffect = executor.execute(postsPerUser)
|
|
819
948
|
|
|
820
|
-
type Rows =
|
|
821
|
-
type Error =
|
|
949
|
+
type Rows = Q.ResultRows<typeof postsPerUser>
|
|
950
|
+
type Error = PostgresExecutor.PostgresQueryError<typeof postsPerUser>
|
|
822
951
|
```
|
|
823
952
|
|
|
953
|
+
Pass `{ renderer }`, `{ driver }`, or both when you need to customize execution.
|
|
954
|
+
|
|
824
955
|
Execution is:
|
|
825
956
|
|
|
826
957
|
1. render the plan
|
|
827
|
-
2.
|
|
828
|
-
3.
|
|
958
|
+
2. normalize raw driver values into canonical runtime values
|
|
959
|
+
3. apply schema-backed transforms where they exist
|
|
960
|
+
4. remap flat aliases into nested objects
|
|
829
961
|
|
|
830
|
-
There is no query
|
|
962
|
+
There is no automatically derived whole-query schema.
|
|
831
963
|
|
|
832
964
|
### Query-sensitive Error Channels
|
|
833
965
|
|
|
@@ -841,10 +973,10 @@ Those types are narrower than the raw dialect error catalogs. For example, known
|
|
|
841
973
|
### Transaction Helpers
|
|
842
974
|
|
|
843
975
|
```ts
|
|
844
|
-
import { Executor } from "effect-qb"
|
|
976
|
+
import { Executor as PostgresExecutor } from "effect-qb/postgres"
|
|
845
977
|
|
|
846
|
-
const transactional =
|
|
847
|
-
const savepoint =
|
|
978
|
+
const transactional = PostgresExecutor.withTransaction(rowsEffect)
|
|
979
|
+
const savepoint = PostgresExecutor.withSavepoint(rowsEffect)
|
|
848
980
|
```
|
|
849
981
|
|
|
850
982
|
These preserve the original effect type parameters and add the ambient SQL transaction boundary.
|
|
@@ -865,8 +997,8 @@ The unusual part is that these are not separate features bolted together. The bu
|
|
|
865
997
|
Both dialect entrypoints expose an `Errors` module:
|
|
866
998
|
|
|
867
999
|
```ts
|
|
868
|
-
import
|
|
869
|
-
import
|
|
1000
|
+
import { Errors as PostgresErrors } from "effect-qb/postgres"
|
|
1001
|
+
import { Errors as MysqlErrors } from "effect-qb/mysql"
|
|
870
1002
|
```
|
|
871
1003
|
|
|
872
1004
|
The catalogs are backed by official vendor references:
|
|
@@ -879,7 +1011,7 @@ That means the tags and descriptor metadata are systematic, not handwritten one-
|
|
|
879
1011
|
Postgres errors normalize around SQLSTATE codes:
|
|
880
1012
|
|
|
881
1013
|
```ts
|
|
882
|
-
const descriptor =
|
|
1014
|
+
const descriptor = PostgresErrors.getPostgresErrorDescriptor("23505")
|
|
883
1015
|
descriptor.tag
|
|
884
1016
|
// "@postgres/integrity-constraint-violation/unique-violation"
|
|
885
1017
|
descriptor.classCode
|
|
@@ -887,7 +1019,7 @@ descriptor.className
|
|
|
887
1019
|
descriptor.condition
|
|
888
1020
|
descriptor.primaryFields
|
|
889
1021
|
|
|
890
|
-
const postgresError =
|
|
1022
|
+
const postgresError = PostgresErrors.normalizePostgresDriverError({
|
|
891
1023
|
code: "23505",
|
|
892
1024
|
message: "duplicate key value violates unique constraint",
|
|
893
1025
|
constraint: "users_email_key"
|
|
@@ -901,7 +1033,7 @@ postgresError.constraintName
|
|
|
901
1033
|
MySQL errors normalize around official symbols and documented numbers:
|
|
902
1034
|
|
|
903
1035
|
```ts
|
|
904
|
-
const descriptor =
|
|
1036
|
+
const descriptor = MysqlErrors.getMysqlErrorDescriptor("ER_DUP_ENTRY")
|
|
905
1037
|
descriptor.tag
|
|
906
1038
|
// "@mysql/server/dup-entry"
|
|
907
1039
|
descriptor.category
|
|
@@ -909,7 +1041,7 @@ descriptor.number
|
|
|
909
1041
|
descriptor.sqlState
|
|
910
1042
|
descriptor.messageTemplate
|
|
911
1043
|
|
|
912
|
-
const mysqlError =
|
|
1044
|
+
const mysqlError = MysqlErrors.normalizeMysqlDriverError({
|
|
913
1045
|
code: "ER_DUP_ENTRY",
|
|
914
1046
|
errno: 1062,
|
|
915
1047
|
sqlState: "23000",
|
|
@@ -958,7 +1090,7 @@ const descriptors =
|
|
|
958
1090
|
|
|
959
1091
|
Executors narrow their error channels based on what the plan is allowed to do.
|
|
960
1092
|
|
|
961
|
-
This happens in the built-in `
|
|
1093
|
+
This happens in the built-in `Executor.make(...)` and `Executor.driver(...)` paths. They normalize the raw failure first, then decide whether the plan should expose the full dialect error surface or the read-only narrowed surface.
|
|
962
1094
|
|
|
963
1095
|
That matters most for read-only plans. If a raw driver error clearly requires write capabilities, the executor does not surface it directly on a read query. It wraps it in a query-requirements error instead:
|
|
964
1096
|
|
|
@@ -979,11 +1111,13 @@ If the plan really is write-bearing, including write CTEs, the original normaliz
|
|
|
979
1111
|
This is reflected at the type level too:
|
|
980
1112
|
|
|
981
1113
|
```ts
|
|
1114
|
+
import { Executor as PostgresExecutor } from "effect-qb/postgres"
|
|
1115
|
+
|
|
982
1116
|
type ReadError =
|
|
983
|
-
|
|
1117
|
+
PostgresExecutor.PostgresQueryError<typeof readPlan>
|
|
984
1118
|
|
|
985
1119
|
type WriteError =
|
|
986
|
-
|
|
1120
|
+
PostgresExecutor.PostgresQueryError<typeof writePlan>
|
|
987
1121
|
```
|
|
988
1122
|
|
|
989
1123
|
For a read-only plan, `ReadError` is the narrowed read-query surface. For a write-bearing plan, `WriteError` is the full normalized Postgres executor error surface. The MySQL executor follows the same rule.
|
|
@@ -992,10 +1126,10 @@ You can also inspect requirements directly:
|
|
|
992
1126
|
|
|
993
1127
|
```ts
|
|
994
1128
|
const postgresRequirements =
|
|
995
|
-
|
|
1129
|
+
PostgresErrors.requirements_of_postgres_error(postgresError)
|
|
996
1130
|
|
|
997
1131
|
const mysqlRequirements =
|
|
998
|
-
|
|
1132
|
+
MysqlErrors.requirements_of_mysql_error(mysqlError)
|
|
999
1133
|
```
|
|
1000
1134
|
|
|
1001
1135
|
### Matching Errors In Application Code
|
|
@@ -1017,15 +1151,15 @@ const rows = executor.execute(plan).pipe(
|
|
|
1017
1151
|
Use the dialect guards for precise narrowing inside shared helpers:
|
|
1018
1152
|
|
|
1019
1153
|
```ts
|
|
1020
|
-
if (
|
|
1154
|
+
if (PostgresErrors.hasSqlState(error, "23505")) {
|
|
1021
1155
|
error.constraintName
|
|
1022
1156
|
}
|
|
1023
1157
|
|
|
1024
|
-
if (
|
|
1158
|
+
if (MysqlErrors.hasSymbol(error, "ER_DUP_ENTRY")) {
|
|
1025
1159
|
error.number
|
|
1026
1160
|
}
|
|
1027
1161
|
|
|
1028
|
-
if (
|
|
1162
|
+
if (MysqlErrors.hasNumber(error, "1062")) {
|
|
1029
1163
|
error.symbol
|
|
1030
1164
|
}
|
|
1031
1165
|
```
|
|
@@ -1076,22 +1210,22 @@ The same branded error shape applies when `where(...)`, joins, or projections re
|
|
|
1076
1210
|
Predicates refine result types, not just SQL.
|
|
1077
1211
|
|
|
1078
1212
|
```ts
|
|
1079
|
-
const
|
|
1213
|
+
const helloPosts = Q.select({
|
|
1080
1214
|
title: posts.title,
|
|
1081
|
-
upperTitle:
|
|
1215
|
+
upperTitle: F.upper(posts.title)
|
|
1082
1216
|
}).pipe(
|
|
1083
1217
|
Q.from(posts),
|
|
1084
|
-
Q.where(Q.
|
|
1218
|
+
Q.where(Q.eq(posts.title, "hello"))
|
|
1085
1219
|
)
|
|
1086
1220
|
|
|
1087
|
-
type
|
|
1221
|
+
type HelloPostsRow = Q.ResultRow<typeof helloPosts>
|
|
1088
1222
|
// {
|
|
1089
1223
|
// title: string
|
|
1090
1224
|
// upperTitle: string
|
|
1091
1225
|
// }
|
|
1092
1226
|
```
|
|
1093
1227
|
|
|
1094
|
-
|
|
1228
|
+
Equality against a non-null literal narrows too. You do not need `isNotNull(...)` to get non-null output.
|
|
1095
1229
|
|
|
1096
1230
|
### Join Optionality
|
|
1097
1231
|
|
|
@@ -1111,9 +1245,24 @@ type MaybePostsRow = Q.ResultRow<typeof maybePosts>
|
|
|
1111
1245
|
// userId: string
|
|
1112
1246
|
// postId: string | null
|
|
1113
1247
|
// }
|
|
1248
|
+
|
|
1249
|
+
const titledPosts = Q.select({
|
|
1250
|
+
userId: users.id,
|
|
1251
|
+
postId: posts.id
|
|
1252
|
+
}).pipe(
|
|
1253
|
+
Q.from(users),
|
|
1254
|
+
Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
|
|
1255
|
+
Q.where(Q.isNotNull(posts.title))
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
type TitledPostsRow = Q.ResultRow<typeof titledPosts>
|
|
1259
|
+
// {
|
|
1260
|
+
// userId: string
|
|
1261
|
+
// postId: string
|
|
1262
|
+
// }
|
|
1114
1263
|
```
|
|
1115
1264
|
|
|
1116
|
-
|
|
1265
|
+
Any non-null proof on the joined table can promote the whole joined source, not just the join key.
|
|
1117
1266
|
|
|
1118
1267
|
### Grouped Query Validation
|
|
1119
1268
|
|
|
@@ -1123,7 +1272,7 @@ Grouped queries are checked structurally:
|
|
|
1123
1272
|
const invalidGroupedPlan = Q.select({
|
|
1124
1273
|
userId: users.id,
|
|
1125
1274
|
title: posts.title,
|
|
1126
|
-
postCount:
|
|
1275
|
+
postCount: F.count(posts.id)
|
|
1127
1276
|
}).pipe(
|
|
1128
1277
|
Q.from(users),
|
|
1129
1278
|
Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
|
|
@@ -1145,29 +1294,24 @@ This catches invalid grouped queries before rendering.
|
|
|
1145
1294
|
Plans, tables, renderers, and executors are dialect-branded.
|
|
1146
1295
|
|
|
1147
1296
|
```ts
|
|
1148
|
-
import
|
|
1149
|
-
import
|
|
1297
|
+
import { Column as MysqlColumn, Query as MysqlQuery, Table as MysqlTable } from "effect-qb/mysql"
|
|
1298
|
+
import { Executor as PostgresExecutor } from "effect-qb/postgres"
|
|
1150
1299
|
|
|
1151
|
-
const mysqlUsers =
|
|
1152
|
-
id:
|
|
1300
|
+
const mysqlUsers = MysqlTable.make("users", {
|
|
1301
|
+
id: MysqlColumn.uuid().pipe(MysqlColumn.primaryKey)
|
|
1153
1302
|
})
|
|
1154
1303
|
|
|
1155
|
-
const mysqlPlan =
|
|
1304
|
+
const mysqlPlan = MysqlQuery.select({
|
|
1156
1305
|
id: mysqlUsers.id
|
|
1157
1306
|
}).pipe(
|
|
1158
|
-
|
|
1307
|
+
MysqlQuery.from(mysqlUsers)
|
|
1159
1308
|
)
|
|
1160
1309
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
//
|
|
1164
|
-
|
|
1165
|
-
//
|
|
1166
|
-
// __effect_qb_plan_dialect__: "mysql"
|
|
1167
|
-
// __effect_qb_target_dialect__: "postgres"
|
|
1168
|
-
// __effect_qb_hint__:
|
|
1169
|
-
// "Use the matching dialect module or renderer/executor"
|
|
1170
|
-
// }
|
|
1310
|
+
const postgresExecutor = PostgresExecutor.make()
|
|
1311
|
+
|
|
1312
|
+
// @ts-expect-error mysql plans are not dialect-compatible with the postgres executor
|
|
1313
|
+
postgresExecutor.execute(mysqlPlan)
|
|
1314
|
+
// effect-qb: plan dialect is not compatible with the target renderer or executor
|
|
1171
1315
|
```
|
|
1172
1316
|
|
|
1173
1317
|
### JSON Schema Compatibility In Mutations
|
|
@@ -1176,6 +1320,7 @@ Schema-backed JSON columns are checked on insert and update.
|
|
|
1176
1320
|
|
|
1177
1321
|
```ts
|
|
1178
1322
|
import * as Schema from "effect/Schema"
|
|
1323
|
+
import { Column as C, Function as F, Query as Q, Table } from "effect-qb/postgres"
|
|
1179
1324
|
|
|
1180
1325
|
const docs = Table.make("docs", {
|
|
1181
1326
|
id: C.uuid().pipe(C.primaryKey),
|
|
@@ -1191,13 +1336,13 @@ const docs = Table.make("docs", {
|
|
|
1191
1336
|
}))
|
|
1192
1337
|
})
|
|
1193
1338
|
|
|
1194
|
-
const cityPath =
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1339
|
+
const cityPath = F.json.path(
|
|
1340
|
+
F.json.key("profile"),
|
|
1341
|
+
F.json.key("address"),
|
|
1342
|
+
F.json.key("city")
|
|
1198
1343
|
)
|
|
1199
1344
|
|
|
1200
|
-
const incompatibleObject =
|
|
1345
|
+
const incompatibleObject = F.json.buildObject({
|
|
1201
1346
|
profile: {
|
|
1202
1347
|
address: {
|
|
1203
1348
|
postcode: "1000"
|
|
@@ -1207,7 +1352,7 @@ const incompatibleObject = Q.json.buildObject({
|
|
|
1207
1352
|
note: null
|
|
1208
1353
|
})
|
|
1209
1354
|
|
|
1210
|
-
const deletedRequiredField =
|
|
1355
|
+
const deletedRequiredField = F.json.delete(docs.payload, cityPath)
|
|
1211
1356
|
|
|
1212
1357
|
Q.insert(docs, {
|
|
1213
1358
|
id: "doc-1",
|
|
@@ -1243,7 +1388,7 @@ That makes invalid plans easier to inspect in editor tooltips and type aliases.
|
|
|
1243
1388
|
|
|
1244
1389
|
### PostgreSQL
|
|
1245
1390
|
|
|
1246
|
-
-
|
|
1391
|
+
- `effect-qb/postgres`
|
|
1247
1392
|
- `distinctOn(...)`
|
|
1248
1393
|
- wider JSON operator surface, including `json.pathMatch(...)`
|
|
1249
1394
|
- schema-qualified tables default to `public`
|
|
@@ -1273,7 +1418,7 @@ Current practical limits:
|
|
|
1273
1418
|
- some features are dialect-specific by design
|
|
1274
1419
|
- JSON support is not identical between Postgres and MySQL
|
|
1275
1420
|
- admin and DDL workflows are not the focus of this README
|
|
1276
|
-
- runtime
|
|
1421
|
+
- runtime schema enforcement follows propagated column and projection schemas, not full query-derived schemas
|
|
1277
1422
|
|
|
1278
1423
|
## Contributing
|
|
1279
1424
|
|
|
@@ -1282,13 +1427,16 @@ Useful commands:
|
|
|
1282
1427
|
```bash
|
|
1283
1428
|
bun test
|
|
1284
1429
|
bun run test:types
|
|
1430
|
+
bun run test:integration
|
|
1431
|
+
bun run release
|
|
1285
1432
|
```
|
|
1286
1433
|
|
|
1287
1434
|
Useful places to start:
|
|
1288
1435
|
|
|
1289
|
-
- [src/
|
|
1436
|
+
- [src/postgres.ts](./src/postgres.ts)
|
|
1290
1437
|
- [src/internal/query-factory.ts](./src/internal/query-factory.ts)
|
|
1291
|
-
- [
|
|
1292
|
-
- [test/
|
|
1438
|
+
- [src/postgres/private/query.ts](./src/postgres/private/query.ts)
|
|
1439
|
+
- [test/public/behavior/query.behavior.test.ts](./test/public/behavior/query.behavior.test.ts)
|
|
1440
|
+
- [test/public/types/query-composition-types.ts](./test/public/types/query-composition-types.ts)
|
|
1293
1441
|
|
|
1294
1442
|
The codebase is organized around typed plans, dialect-specialized entrypoints, and behavior-first tests.
|