effect-qb 0.13.0 → 0.14.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/README.md +6 -1431
- package/dist/mysql.js +1678 -355
- package/dist/postgres/metadata.js +2724 -0
- package/dist/postgres.js +7197 -5433
- package/package.json +8 -10
- package/src/internal/column-state.ts +84 -10
- package/src/internal/column.ts +556 -34
- package/src/internal/datatypes/define.ts +0 -30
- package/src/internal/executor.ts +45 -11
- package/src/internal/expression-ast.ts +4 -0
- package/src/internal/expression.ts +1 -1
- package/src/internal/implication-runtime.ts +171 -0
- package/src/internal/mysql-query.ts +7173 -0
- package/src/internal/mysql-renderer.ts +2 -2
- package/src/internal/plan.ts +14 -4
- package/src/internal/{query-factory.ts → postgres-query.ts} +619 -167
- package/src/internal/postgres-renderer.ts +2 -2
- package/src/internal/postgres-schema-model.ts +144 -0
- package/src/internal/predicate-analysis.ts +10 -0
- package/src/internal/predicate-context.ts +112 -36
- package/src/internal/predicate-formula.ts +31 -19
- package/src/internal/predicate-normalize.ts +177 -106
- package/src/internal/predicate-runtime.ts +676 -0
- package/src/internal/query.ts +455 -39
- package/src/internal/renderer.ts +2 -2
- package/src/internal/runtime-schema.ts +74 -20
- package/src/internal/schema-ddl.ts +55 -0
- package/src/internal/schema-derivation.ts +93 -21
- package/src/internal/schema-expression.ts +44 -0
- package/src/internal/sql-expression-renderer.ts +95 -31
- package/src/internal/table-options.ts +87 -7
- package/src/internal/table.ts +104 -41
- package/src/mysql/column.ts +1 -0
- package/src/mysql/datatypes/index.ts +17 -2
- package/src/mysql/function/core.ts +1 -0
- package/src/mysql/function/index.ts +1 -0
- package/src/mysql/private/query.ts +1 -13
- package/src/mysql/query.ts +5 -0
- package/src/postgres/cast.ts +31 -0
- package/src/postgres/column.ts +26 -0
- package/src/postgres/datatypes/index.ts +40 -5
- package/src/postgres/function/core.ts +12 -0
- package/src/postgres/function/index.ts +2 -1
- package/src/postgres/function/json.ts +499 -2
- package/src/postgres/metadata.ts +31 -0
- package/src/postgres/private/query.ts +1 -13
- package/src/postgres/query.ts +5 -2
- package/src/postgres/schema-expression.ts +16 -0
- package/src/postgres/schema-management.ts +204 -0
- package/src/postgres/schema.ts +35 -0
- package/src/postgres/table.ts +307 -41
- package/src/postgres/type.ts +4 -0
- package/src/postgres.ts +14 -0
- package/CHANGELOG.md +0 -134
package/README.md
CHANGED
|
@@ -1,1442 +1,17 @@
|
|
|
1
1
|
# effect-qb
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`effect-qb` is the typed SQL querybuilder package in this workspace.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Install
|
|
6
6
|
|
|
7
|
-
|
|
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
|
|
7
|
+
```sh
|
|
35
8
|
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
9
|
```
|
|
45
10
|
|
|
46
|
-
|
|
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
|
-
|
|
61
|
-
## Table of Contents
|
|
62
|
-
|
|
63
|
-
- [Choose An Entrypoint](#choose-an-entrypoint)
|
|
64
|
-
- [Postgres Function](#postgres-function)
|
|
65
|
-
- [Quick Start](#quick-start)
|
|
66
|
-
- [Execution Model](#execution-model)
|
|
67
|
-
- [Feature Map](#feature-map)
|
|
68
|
-
- [Effect Schema Integration](#effect-schema-integration)
|
|
69
|
-
- [Core Concepts](#core-concepts)
|
|
70
|
-
- [Derived Table Schemas](#derived-table-schemas)
|
|
71
|
-
- [Tables And Columns](#tables-and-columns)
|
|
72
|
-
- [Plans, Not Strings](#plans-not-strings)
|
|
73
|
-
- [ResultRow vs RuntimeResultRow](#resultrow-vs-runtimeresultrow)
|
|
74
|
-
- [Schema-backed JSON Columns](#schema-backed-json-columns)
|
|
75
|
-
- [Dialect-specific Entrypoints](#dialect-specific-entrypoints)
|
|
76
|
-
- [Query Guide](#query-guide)
|
|
77
|
-
- [Selecting Data](#selecting-data)
|
|
78
|
-
- [Bringing Sources Into Scope](#bringing-sources-into-scope)
|
|
79
|
-
- [Filtering Rows](#filtering-rows)
|
|
80
|
-
- [Shaping Results](#shaping-results)
|
|
81
|
-
- [Aggregating](#aggregating)
|
|
82
|
-
- [Combining Queries](#combining-queries)
|
|
83
|
-
- [Controlling Result Sets](#controlling-result-sets)
|
|
84
|
-
- [Mutations](#mutations)
|
|
85
|
-
- [Insert](#insert)
|
|
86
|
-
- [Update](#update)
|
|
87
|
-
- [Delete](#delete)
|
|
88
|
-
- [Conflicts And Upserts](#conflicts-and-upserts)
|
|
89
|
-
- [Returning](#returning)
|
|
90
|
-
- [Data-modifying CTEs](#data-modifying-ctes)
|
|
91
|
-
- [Rendering And Execution](#rendering-and-execution)
|
|
92
|
-
- [Renderer](#renderer)
|
|
93
|
-
- [Executor](#executor)
|
|
94
|
-
- [Query-sensitive Error Channels](#query-sensitive-error-channels)
|
|
95
|
-
- [Transaction Helpers](#transaction-helpers)
|
|
96
|
-
- [Error Handling](#error-handling)
|
|
97
|
-
- [Catalogs And Normalization](#catalogs-and-normalization)
|
|
98
|
-
- [Query-capability Narrowing](#query-capability-narrowing)
|
|
99
|
-
- [Matching Errors In Application Code](#matching-errors-in-application-code)
|
|
100
|
-
- [Type Safety](#type-safety)
|
|
101
|
-
- [Complete-plan Enforcement](#complete-plan-enforcement)
|
|
102
|
-
- [Predicate-driven Narrowing](#predicate-driven-narrowing)
|
|
103
|
-
- [Join Optionality](#join-optionality)
|
|
104
|
-
- [Grouped Query Validation](#grouped-query-validation)
|
|
105
|
-
- [Dialect Compatibility](#dialect-compatibility)
|
|
106
|
-
- [JSON Schema Compatibility In Mutations](#json-schema-compatibility-in-mutations)
|
|
107
|
-
- [Readable Branded Type Errors](#readable-branded-type-errors)
|
|
108
|
-
- [Dialect Support](#dialect-support)
|
|
109
|
-
- [PostgreSQL](#postgresql)
|
|
110
|
-
- [MySQL](#mysql)
|
|
111
|
-
- [Limitations](#limitations)
|
|
112
|
-
- [Contributing](#contributing)
|
|
113
|
-
|
|
114
|
-
## Choose An Entrypoint
|
|
115
|
-
|
|
116
|
-
Available entrypoints:
|
|
11
|
+
## Entry points
|
|
117
12
|
|
|
118
13
|
- `effect-qb/postgres`
|
|
119
14
|
- `effect-qb/mysql`
|
|
15
|
+
- `effect-qb/postgres/metadata`
|
|
120
16
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
That entrypoint also exposes `Postgres.Function` for typed SQL functions and JSON helpers.
|
|
124
|
-
|
|
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.
|
|
126
|
-
|
|
127
|
-
## Postgres Function
|
|
128
|
-
|
|
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.
|
|
130
|
-
|
|
131
|
-
```ts
|
|
132
|
-
import { Column as C, Function as F, Query as Q, Table } from "effect-qb/postgres"
|
|
133
|
-
|
|
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
|
-
})
|
|
139
|
-
|
|
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
|
-
)
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
`Postgres.Function` currently covers:
|
|
150
|
-
|
|
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`
|
|
156
|
-
|
|
157
|
-
## Quick Start
|
|
158
|
-
|
|
159
|
-
```ts
|
|
160
|
-
import { Column as C, Function as F, Query as Q, Renderer, Table } from "effect-qb/postgres"
|
|
161
|
-
|
|
162
|
-
const users = Table.make("users", {
|
|
163
|
-
id: C.uuid().pipe(C.primaryKey),
|
|
164
|
-
email: C.text()
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
const posts = Table.make("posts", {
|
|
168
|
-
id: C.uuid().pipe(C.primaryKey),
|
|
169
|
-
userId: C.uuid(),
|
|
170
|
-
title: C.text().pipe(C.nullable)
|
|
171
|
-
})
|
|
172
|
-
|
|
173
|
-
const postsPerUser = Q.select({
|
|
174
|
-
userId: users.id,
|
|
175
|
-
email: users.email,
|
|
176
|
-
postCount: F.count(posts.id)
|
|
177
|
-
}).pipe(
|
|
178
|
-
Q.from(users),
|
|
179
|
-
Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
|
|
180
|
-
Q.groupBy(users.id, users.email),
|
|
181
|
-
Q.orderBy(users.email)
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
type PostsPerUserRow = Q.ResultRow<typeof postsPerUser>
|
|
185
|
-
// {
|
|
186
|
-
// userId: string
|
|
187
|
-
// email: string
|
|
188
|
-
// postCount: number
|
|
189
|
-
// }
|
|
190
|
-
|
|
191
|
-
const rendered = Renderer.make().render(postsPerUser)
|
|
192
|
-
rendered.sql
|
|
193
|
-
rendered.params
|
|
194
|
-
```
|
|
195
|
-
|
|
196
|
-
This is the core model: define typed tables, build a plan, let the plan define the row type, then render or execute it.
|
|
197
|
-
|
|
198
|
-
## Execution Model
|
|
199
|
-
|
|
200
|
-
The runtime model is intentionally small:
|
|
201
|
-
|
|
202
|
-
1. build a typed plan
|
|
203
|
-
2. render SQL plus bind params
|
|
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
|
|
207
|
-
|
|
208
|
-
For the logical/static row split, see `ResultRow vs RuntimeResultRow` below.
|
|
209
|
-
|
|
210
|
-
```ts
|
|
211
|
-
import * as SqlClient from "@effect/sql/SqlClient"
|
|
212
|
-
import * as Effect from "effect/Effect"
|
|
213
|
-
import { Executor as PostgresExecutor } from "effect-qb/postgres"
|
|
214
|
-
|
|
215
|
-
const executor = PostgresExecutor.make()
|
|
216
|
-
|
|
217
|
-
const rowsEffect = executor.execute(postsPerUser)
|
|
218
|
-
|
|
219
|
-
const rows = Effect.runSync(
|
|
220
|
-
Effect.provideService(rowsEffect, SqlClient.SqlClient, sqlClient)
|
|
221
|
-
)
|
|
222
|
-
```
|
|
223
|
-
|
|
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.
|
|
225
|
-
|
|
226
|
-
## Feature Map
|
|
227
|
-
|
|
228
|
-
The rest of this README goes deeper, but the main surface area is:
|
|
229
|
-
|
|
230
|
-
- table builders with keys, indexes, nullability, defaults, and schema-backed JSON columns
|
|
231
|
-
- select plans with joins, CTEs, derived tables, `values(...)`, `unnest(...)`, subqueries, and set operators
|
|
232
|
-
- mutation plans for `insert`, `update`, `delete`, `returning`, and conflict handling
|
|
233
|
-
- typed SQL functions via `Postgres.Function` and `Mysql.Function`
|
|
234
|
-
- renderers and executors for Postgres and MySQL
|
|
235
|
-
- type-level checks for missing sources, grouped selections, dialect compatibility, and JSON mutation compatibility
|
|
236
|
-
|
|
237
|
-
Dialect-specific capabilities are called out later. Postgres currently has the wider feature surface in a few areas such as `distinctOn(...)`, `generateSeries(...)`, and some JSON operators.
|
|
238
|
-
|
|
239
|
-
## Effect Schema Integration
|
|
240
|
-
|
|
241
|
-
`effect-qb` uses `effect/Schema` as the runtime contract for columns and tables.
|
|
242
|
-
|
|
243
|
-
That integration shows up in three places:
|
|
244
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
The schema-aware column APIs are:
|
|
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
|
|
253
|
-
- `C.custom(schema, dbType)` for arbitrary non-JSON columns
|
|
254
|
-
- `C.json(schema)` for JSON columns
|
|
255
|
-
|
|
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
|
|
263
|
-
|
|
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`
|
|
268
|
-
|
|
269
|
-
Example:
|
|
270
|
-
|
|
271
|
-
```ts
|
|
272
|
-
import * as Schema from "effect/Schema"
|
|
273
|
-
import { Column as C, Executor, Function as F, Query as Q, Table } from "effect-qb/postgres"
|
|
274
|
-
|
|
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()))
|
|
282
|
-
})
|
|
283
|
-
|
|
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"
|
|
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)
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
From that one definition you get:
|
|
314
|
-
|
|
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
|
|
319
|
-
|
|
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`.
|
|
321
|
-
|
|
322
|
-
The boundary is still important:
|
|
323
|
-
|
|
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
|
|
327
|
-
|
|
328
|
-
## Core Concepts
|
|
329
|
-
|
|
330
|
-
### Derived Table Schemas
|
|
331
|
-
|
|
332
|
-
Every table exposes derived Effect Schemas:
|
|
333
|
-
|
|
334
|
-
```ts
|
|
335
|
-
import * as Schema from "effect/Schema"
|
|
336
|
-
|
|
337
|
-
const users = Table.make("users", {
|
|
338
|
-
id: C.uuid().pipe(C.primaryKey, C.generated(Q.literal("generated-user-id"))),
|
|
339
|
-
email: C.text().pipe(C.unique),
|
|
340
|
-
bio: C.text().pipe(C.nullable),
|
|
341
|
-
createdAt: C.timestamp().pipe(C.default(F.localTimestamp()))
|
|
342
|
-
})
|
|
343
|
-
|
|
344
|
-
Schema.isSchema(users.schemas.select)
|
|
345
|
-
Schema.isSchema(users.schemas.insert)
|
|
346
|
-
Schema.isSchema(users.schemas.update)
|
|
347
|
-
```
|
|
348
|
-
|
|
349
|
-
Those schemas are derived from column metadata, not maintained separately.
|
|
350
|
-
|
|
351
|
-
- `select` includes every column, with nullable columns wrapped in `Schema.NullOr(...)`
|
|
352
|
-
- `insert` omits generated columns and makes nullable/defaulted columns optional
|
|
353
|
-
- `update` omits generated columns and primary-key columns and makes the remaining columns optional
|
|
354
|
-
|
|
355
|
-
This is the main runtime bridge between the SQL DSL and Effect Schema. You can validate table payloads with the derived schemas without duplicating the model elsewhere.
|
|
356
|
-
|
|
357
|
-
### Tables And Columns
|
|
358
|
-
|
|
359
|
-
Tables are typed sources, not loose name strings. Columns carry DB types, nullability, defaults, keys, and schema-backed JSON information.
|
|
360
|
-
|
|
361
|
-
```ts
|
|
362
|
-
import * as Schema from "effect/Schema"
|
|
363
|
-
import { Column as C, Table } from "effect-qb/postgres"
|
|
364
|
-
|
|
365
|
-
const users = Table.make("users", {
|
|
366
|
-
id: C.uuid().pipe(C.primaryKey),
|
|
367
|
-
email: C.text(),
|
|
368
|
-
profile: C.json(Schema.Struct({
|
|
369
|
-
displayName: Schema.String,
|
|
370
|
-
bio: Schema.NullOr(Schema.String)
|
|
371
|
-
}))
|
|
372
|
-
})
|
|
373
|
-
```
|
|
374
|
-
|
|
375
|
-
Schema-qualified tables are also typed:
|
|
376
|
-
|
|
377
|
-
```ts
|
|
378
|
-
const analytics = Table.schema("analytics")
|
|
379
|
-
|
|
380
|
-
const events = analytics.table("events", {
|
|
381
|
-
id: C.uuid().pipe(C.primaryKey),
|
|
382
|
-
userId: C.uuid()
|
|
383
|
-
})
|
|
384
|
-
```
|
|
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
|
-
|
|
421
|
-
### Plans, Not Strings
|
|
422
|
-
|
|
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.
|
|
424
|
-
|
|
425
|
-
That distinction is important:
|
|
426
|
-
|
|
427
|
-
- you can reference sources before they are in scope while composing
|
|
428
|
-
- the type system tracks what is still missing
|
|
429
|
-
- `render(...)`, `execute(...)`, and `Q.CompletePlan<typeof plan>` are the enforcement boundary
|
|
430
|
-
|
|
431
|
-
### ResultRow vs RuntimeResultRow
|
|
432
|
-
|
|
433
|
-
`Q.ResultRow<typeof plan>` is the logical result type after static analysis. It includes things like:
|
|
434
|
-
|
|
435
|
-
- `where(isNotNull(...))` nullability refinement
|
|
436
|
-
- left-join promotion when predicates prove presence
|
|
437
|
-
- grouped-query validation
|
|
438
|
-
- branch pruning for expressions like `case()`
|
|
439
|
-
|
|
440
|
-
`Q.RuntimeResultRow<typeof plan>` is intentionally more conservative. It describes the schema-free runtime remap path only.
|
|
441
|
-
|
|
442
|
-
```ts
|
|
443
|
-
const guaranteedPost = Q.select({
|
|
444
|
-
userId: users.id,
|
|
445
|
-
postId: posts.id
|
|
446
|
-
}).pipe(
|
|
447
|
-
Q.from(users),
|
|
448
|
-
Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
|
|
449
|
-
Q.where(Q.isNotNull(posts.id))
|
|
450
|
-
)
|
|
451
|
-
|
|
452
|
-
type LogicalRow = Q.ResultRow<typeof guaranteedPost>
|
|
453
|
-
// {
|
|
454
|
-
// userId: string
|
|
455
|
-
// postId: string
|
|
456
|
-
// }
|
|
457
|
-
|
|
458
|
-
type RuntimeRow = Q.RuntimeResultRow<typeof guaranteedPost>
|
|
459
|
-
// {
|
|
460
|
-
// userId: string
|
|
461
|
-
// postId: string | null
|
|
462
|
-
// }
|
|
463
|
-
```
|
|
464
|
-
|
|
465
|
-
### Schema-backed JSON Columns
|
|
466
|
-
|
|
467
|
-
JSON columns can carry a schema. That schema feeds:
|
|
468
|
-
|
|
469
|
-
- JSON path typing
|
|
470
|
-
- JSON manipulation result typing
|
|
471
|
-
- insert/update compatibility checks
|
|
472
|
-
|
|
473
|
-
```ts
|
|
474
|
-
import * as Schema from "effect/Schema"
|
|
475
|
-
|
|
476
|
-
const docs = Table.make("docs", {
|
|
477
|
-
id: C.uuid().pipe(C.primaryKey),
|
|
478
|
-
payload: C.json(Schema.Struct({
|
|
479
|
-
profile: Schema.Struct({
|
|
480
|
-
address: Schema.Struct({
|
|
481
|
-
city: Schema.String,
|
|
482
|
-
postcode: Schema.NullOr(Schema.String)
|
|
483
|
-
})
|
|
484
|
-
})
|
|
485
|
-
}))
|
|
486
|
-
})
|
|
487
|
-
|
|
488
|
-
const cityPath = F.json.path(
|
|
489
|
-
F.json.key("profile"),
|
|
490
|
-
F.json.key("address"),
|
|
491
|
-
F.json.key("city")
|
|
492
|
-
)
|
|
493
|
-
|
|
494
|
-
const city = F.json.get(docs.payload, cityPath)
|
|
495
|
-
type City = Q.OutputOfExpression<typeof city, {
|
|
496
|
-
readonly docs: {
|
|
497
|
-
readonly name: "docs"
|
|
498
|
-
readonly mode: "required"
|
|
499
|
-
}
|
|
500
|
-
}>
|
|
501
|
-
// string
|
|
502
|
-
```
|
|
503
|
-
|
|
504
|
-
### Dialect-specific Entrypoints
|
|
505
|
-
|
|
506
|
-
Dialect entrypoints expose dialect-specific builders:
|
|
507
|
-
|
|
508
|
-
```ts
|
|
509
|
-
import { Query as PostgresQuery } from "effect-qb/postgres"
|
|
510
|
-
import { Query as MysqlQuery } from "effect-qb/mysql"
|
|
511
|
-
```
|
|
512
|
-
|
|
513
|
-
This matters for:
|
|
514
|
-
|
|
515
|
-
- dialect-locked tables and columns
|
|
516
|
-
- dialect-only features like Postgres `distinctOn(...)`
|
|
517
|
-
- dialect-specific renderers and executors
|
|
518
|
-
- dialect-specific error unions
|
|
519
|
-
|
|
520
|
-
## Query Guide
|
|
521
|
-
|
|
522
|
-
### Selecting Data
|
|
523
|
-
|
|
524
|
-
Selections define the result type directly. Nested objects stay nested in the row type.
|
|
525
|
-
|
|
526
|
-
```ts
|
|
527
|
-
const listUsers = Q.select({
|
|
528
|
-
id: users.id,
|
|
529
|
-
profile: {
|
|
530
|
-
email: users.email
|
|
531
|
-
},
|
|
532
|
-
hasPosts: Q.literal(true)
|
|
533
|
-
}).pipe(
|
|
534
|
-
Q.from(users)
|
|
535
|
-
)
|
|
536
|
-
|
|
537
|
-
type ListUsersRow = Q.ResultRow<typeof listUsers>
|
|
538
|
-
// {
|
|
539
|
-
// id: string
|
|
540
|
-
// profile: {
|
|
541
|
-
// email: string
|
|
542
|
-
// }
|
|
543
|
-
// hasPosts: boolean
|
|
544
|
-
// }
|
|
545
|
-
```
|
|
546
|
-
|
|
547
|
-
Projection typing is local. You usually do not need to define row interfaces yourself.
|
|
548
|
-
|
|
549
|
-
### Bringing Sources Into Scope
|
|
550
|
-
|
|
551
|
-
`from(...)` and joins make referenced sources available to the plan. Derived tables, CTEs, and correlated sources stay typed.
|
|
552
|
-
|
|
553
|
-
```ts
|
|
554
|
-
const activePosts = Q.select({
|
|
555
|
-
userId: posts.userId,
|
|
556
|
-
title: posts.title
|
|
557
|
-
}).pipe(
|
|
558
|
-
Q.from(posts),
|
|
559
|
-
Q.where(Q.isNotNull(posts.title)),
|
|
560
|
-
Q.as("active_posts")
|
|
561
|
-
)
|
|
562
|
-
|
|
563
|
-
const usersWithPosts = Q.select({
|
|
564
|
-
userId: users.id,
|
|
565
|
-
title: activePosts.title
|
|
566
|
-
}).pipe(
|
|
567
|
-
Q.from(users),
|
|
568
|
-
Q.innerJoin(activePosts, Q.eq(users.id, activePosts.userId))
|
|
569
|
-
)
|
|
570
|
-
|
|
571
|
-
type UsersWithPostsRow = Q.ResultRow<typeof usersWithPosts>
|
|
572
|
-
// {
|
|
573
|
-
// userId: string
|
|
574
|
-
// title: string
|
|
575
|
-
// }
|
|
576
|
-
```
|
|
577
|
-
|
|
578
|
-
The same source story applies to:
|
|
579
|
-
|
|
580
|
-
- `subquery.pipe(Q.with("alias"))`
|
|
581
|
-
- `subquery.pipe(Q.withRecursive("alias"))`
|
|
582
|
-
- `subquery.pipe(Q.lateral("alias"))`
|
|
583
|
-
- `Q.values(...)`
|
|
584
|
-
- `Q.unnest(...)`
|
|
585
|
-
|
|
586
|
-
### Filtering Rows
|
|
587
|
-
|
|
588
|
-
Predicates do more than render SQL. They can narrow result types.
|
|
589
|
-
|
|
590
|
-
```ts
|
|
591
|
-
const allPosts = Q.select({
|
|
592
|
-
title: posts.title,
|
|
593
|
-
upperTitle: F.upper(posts.title)
|
|
594
|
-
}).pipe(
|
|
595
|
-
Q.from(posts)
|
|
596
|
-
)
|
|
597
|
-
|
|
598
|
-
type AllPostsRow = Q.ResultRow<typeof allPosts>
|
|
599
|
-
// {
|
|
600
|
-
// title: string | null
|
|
601
|
-
// upperTitle: string | null
|
|
602
|
-
// }
|
|
603
|
-
|
|
604
|
-
const titledPosts = Q.select({
|
|
605
|
-
title: posts.title,
|
|
606
|
-
upperTitle: F.upper(posts.title)
|
|
607
|
-
}).pipe(
|
|
608
|
-
Q.from(posts),
|
|
609
|
-
Q.where(Q.isNotNull(posts.title))
|
|
610
|
-
)
|
|
611
|
-
|
|
612
|
-
type TitledPostsRow = Q.ResultRow<typeof titledPosts>
|
|
613
|
-
// {
|
|
614
|
-
// title: string
|
|
615
|
-
// upperTitle: string
|
|
616
|
-
// }
|
|
617
|
-
```
|
|
618
|
-
|
|
619
|
-
That same narrowing feeds:
|
|
620
|
-
|
|
621
|
-
- `coalesce(...)`
|
|
622
|
-
- `case()`
|
|
623
|
-
- `match(...)`
|
|
624
|
-
- joined-source promotion
|
|
625
|
-
|
|
626
|
-
### Shaping Results
|
|
627
|
-
|
|
628
|
-
The expression surface is large, but the important point is that result-shaping expressions stay typed.
|
|
629
|
-
|
|
630
|
-
```ts
|
|
631
|
-
import * as Schema from "effect/Schema"
|
|
632
|
-
import { Column as C, Function as F, Query as Q, Table } from "effect-qb/postgres"
|
|
633
|
-
|
|
634
|
-
const docs = Table.make("docs", {
|
|
635
|
-
id: C.uuid().pipe(C.primaryKey),
|
|
636
|
-
payload: C.json(Schema.Struct({
|
|
637
|
-
profile: Schema.Struct({
|
|
638
|
-
address: Schema.Struct({
|
|
639
|
-
city: Schema.String
|
|
640
|
-
})
|
|
641
|
-
})
|
|
642
|
-
}))
|
|
643
|
-
})
|
|
644
|
-
|
|
645
|
-
const cityPath = F.json.path(
|
|
646
|
-
F.json.key("profile"),
|
|
647
|
-
F.json.key("address"),
|
|
648
|
-
F.json.key("city")
|
|
649
|
-
)
|
|
650
|
-
|
|
651
|
-
const shapedDocs = Q.select({
|
|
652
|
-
title: Q.case()
|
|
653
|
-
.when(Q.isNull(posts.title), "missing")
|
|
654
|
-
.else(F.upper(posts.title)),
|
|
655
|
-
profileCity: F.json.text(docs.payload, cityPath),
|
|
656
|
-
titleAsText: Q.cast(posts.title, Q.type.text())
|
|
657
|
-
}).pipe(
|
|
658
|
-
Q.from(posts),
|
|
659
|
-
Q.leftJoin(docs, Q.eq(posts.id, docs.id))
|
|
660
|
-
)
|
|
661
|
-
```
|
|
662
|
-
|
|
663
|
-
The same JSON path object can be reused across:
|
|
664
|
-
|
|
665
|
-
- `Function.json.get(...)`
|
|
666
|
-
- `Function.json.text(...)`
|
|
667
|
-
- `Function.json.set(...)`
|
|
668
|
-
- `Function.json.insert(...)`
|
|
669
|
-
- `Function.json.delete(...)`
|
|
670
|
-
- `Function.json.pathExists(...)`
|
|
671
|
-
|
|
672
|
-
Comparison and cast safety are dialect-aware. Incompatible operands are rejected unless you make the conversion explicit with `Q.cast(...)`.
|
|
673
|
-
|
|
674
|
-
### Aggregating
|
|
675
|
-
|
|
676
|
-
Grouped queries are checked structurally, not just by source provenance.
|
|
677
|
-
|
|
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
|
-
|
|
696
|
-
const postsPerUser = Q.select({
|
|
697
|
-
userId: users.id,
|
|
698
|
-
postCount: F.count(posts.id)
|
|
699
|
-
}).pipe(
|
|
700
|
-
Q.from(users),
|
|
701
|
-
Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
|
|
702
|
-
Q.groupBy(users.id)
|
|
703
|
-
)
|
|
704
|
-
|
|
705
|
-
type PostsPerUserRow = Q.ResultRow<typeof postsPerUser>
|
|
706
|
-
// {
|
|
707
|
-
// userId: string
|
|
708
|
-
// postCount: number
|
|
709
|
-
// }
|
|
710
|
-
```
|
|
711
|
-
|
|
712
|
-
This catches invalid grouped queries before rendering, then the fixed plan keeps only grouped or aggregate selections.
|
|
713
|
-
|
|
714
|
-
### Combining Queries
|
|
715
|
-
|
|
716
|
-
Subqueries and set operators stay part of the same typed plan model.
|
|
717
|
-
|
|
718
|
-
```ts
|
|
719
|
-
const postsByUser = Q.select({
|
|
720
|
-
id: posts.id
|
|
721
|
-
}).pipe(
|
|
722
|
-
Q.from(posts),
|
|
723
|
-
Q.where(Q.eq(posts.userId, users.id))
|
|
724
|
-
)
|
|
725
|
-
|
|
726
|
-
const usersWithPosts = Q.select({
|
|
727
|
-
userId: users.id,
|
|
728
|
-
hasPosts: Q.exists(postsByUser)
|
|
729
|
-
}).pipe(
|
|
730
|
-
Q.from(users)
|
|
731
|
-
)
|
|
732
|
-
|
|
733
|
-
type UsersWithPostsRow = Q.ResultRow<typeof usersWithPosts>
|
|
734
|
-
// {
|
|
735
|
-
// userId: string
|
|
736
|
-
// hasPosts: boolean
|
|
737
|
-
// }
|
|
738
|
-
```
|
|
739
|
-
|
|
740
|
-
Set operators require compatible row shapes:
|
|
741
|
-
|
|
742
|
-
- `Q.union(...)`
|
|
743
|
-
- `Q.unionAll(...)`
|
|
744
|
-
- `Q.intersect(...)`
|
|
745
|
-
- `Q.intersectAll(...)`
|
|
746
|
-
- `Q.except(...)`
|
|
747
|
-
- `Q.exceptAll(...)`
|
|
748
|
-
|
|
749
|
-
### Controlling Result Sets
|
|
750
|
-
|
|
751
|
-
Ordering and result-set controls are regular plan transforms:
|
|
752
|
-
|
|
753
|
-
```ts
|
|
754
|
-
const recentUsers = Q.select({
|
|
755
|
-
id: users.id,
|
|
756
|
-
email: users.email
|
|
757
|
-
}).pipe(
|
|
758
|
-
Q.from(users),
|
|
759
|
-
Q.distinct(),
|
|
760
|
-
Q.orderBy(users.email),
|
|
761
|
-
Q.limit(10),
|
|
762
|
-
Q.offset(20)
|
|
763
|
-
)
|
|
764
|
-
```
|
|
765
|
-
|
|
766
|
-
Postgres-only `distinct on` is available from the Postgres entrypoint:
|
|
767
|
-
|
|
768
|
-
```ts
|
|
769
|
-
import { Query as Q } from "effect-qb/postgres"
|
|
770
|
-
|
|
771
|
-
const recentEmails = Q.select({
|
|
772
|
-
id: users.id,
|
|
773
|
-
email: users.email
|
|
774
|
-
}).pipe(
|
|
775
|
-
Q.from(users),
|
|
776
|
-
Q.distinctOn(users.email),
|
|
777
|
-
Q.orderBy(users.email)
|
|
778
|
-
)
|
|
779
|
-
```
|
|
780
|
-
|
|
781
|
-
## Mutations
|
|
782
|
-
|
|
783
|
-
### Insert
|
|
784
|
-
|
|
785
|
-
Single-row inserts are direct:
|
|
786
|
-
|
|
787
|
-
```ts
|
|
788
|
-
const insertUser = Q.insert(users, {
|
|
789
|
-
id: "user-1",
|
|
790
|
-
email: "alice@example.com"
|
|
791
|
-
})
|
|
792
|
-
```
|
|
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
|
-
|
|
796
|
-
Composable sources are available when the input rows come from elsewhere:
|
|
797
|
-
|
|
798
|
-
```ts
|
|
799
|
-
const pendingUsers = Q.values([
|
|
800
|
-
{ id: "user-1", email: "alice@example.com" },
|
|
801
|
-
{ id: "user-2", email: "bob@example.com" }
|
|
802
|
-
]).pipe(
|
|
803
|
-
Q.as("pending_users")
|
|
804
|
-
)
|
|
805
|
-
|
|
806
|
-
const insertMany = Q.insert(users).pipe(
|
|
807
|
-
Q.from(pendingUsers)
|
|
808
|
-
)
|
|
809
|
-
```
|
|
810
|
-
|
|
811
|
-
`from(...)` also accepts `select(...)`, `unnest(...)`, and other compatible sources.
|
|
812
|
-
|
|
813
|
-
### Update
|
|
814
|
-
|
|
815
|
-
Updates stay expression-aware and can use `from(...)` sources where the dialect supports it.
|
|
816
|
-
|
|
817
|
-
```ts
|
|
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
|
-
))
|
|
826
|
-
)
|
|
827
|
-
```
|
|
828
|
-
|
|
829
|
-
The assigned values still have to be type-compatible with the target columns.
|
|
830
|
-
|
|
831
|
-
### Delete
|
|
832
|
-
|
|
833
|
-
Deletes keep their own statement kind and can also participate in typed conditions and `returning(...)`.
|
|
834
|
-
|
|
835
|
-
```ts
|
|
836
|
-
const deleteUser = Q.delete(users).pipe(
|
|
837
|
-
Q.where(Q.eq(users.id, "user-1"))
|
|
838
|
-
)
|
|
839
|
-
```
|
|
840
|
-
|
|
841
|
-
### Conflicts And Upserts
|
|
842
|
-
|
|
843
|
-
Conflict handling is modeled as a composable modifier instead of a string escape hatch.
|
|
844
|
-
|
|
845
|
-
```ts
|
|
846
|
-
const insertOrIgnore = Q.insert(users, {
|
|
847
|
-
id: "user-1",
|
|
848
|
-
email: "alice@example.com"
|
|
849
|
-
}).pipe(
|
|
850
|
-
Q.onConflict(["id"] as const, {
|
|
851
|
-
action: "doNothing"
|
|
852
|
-
})
|
|
853
|
-
)
|
|
854
|
-
|
|
855
|
-
const upsertUser = Q.insert(users, {
|
|
856
|
-
id: "user-1",
|
|
857
|
-
email: "alice@example.com"
|
|
858
|
-
}).pipe(
|
|
859
|
-
Q.onConflict(["id"] as const, {
|
|
860
|
-
update: {
|
|
861
|
-
email: Q.excluded(users.email)
|
|
862
|
-
}
|
|
863
|
-
})
|
|
864
|
-
)
|
|
865
|
-
```
|
|
866
|
-
|
|
867
|
-
Conflict targets are checked against the target table.
|
|
868
|
-
|
|
869
|
-
### Returning
|
|
870
|
-
|
|
871
|
-
Mutation plans can project typed rows with `returning(...)`.
|
|
872
|
-
|
|
873
|
-
```ts
|
|
874
|
-
const insertedUser = Q.insert(users, {
|
|
875
|
-
id: "user-1",
|
|
876
|
-
email: "alice@example.com"
|
|
877
|
-
}).pipe(
|
|
878
|
-
Q.returning({
|
|
879
|
-
id: users.id,
|
|
880
|
-
email: users.email
|
|
881
|
-
})
|
|
882
|
-
)
|
|
883
|
-
|
|
884
|
-
type InsertedUserRow = Q.ResultRow<typeof insertedUser>
|
|
885
|
-
// {
|
|
886
|
-
// id: string
|
|
887
|
-
// email: string
|
|
888
|
-
// }
|
|
889
|
-
```
|
|
890
|
-
|
|
891
|
-
### Data-modifying CTEs
|
|
892
|
-
|
|
893
|
-
Write plans can feed later reads in the same statement:
|
|
894
|
-
|
|
895
|
-
```ts
|
|
896
|
-
const insertedUsers = Q.insert(users, {
|
|
897
|
-
id: "user-1",
|
|
898
|
-
email: "alice@example.com"
|
|
899
|
-
}).pipe(
|
|
900
|
-
Q.returning({
|
|
901
|
-
id: users.id,
|
|
902
|
-
email: users.email
|
|
903
|
-
}),
|
|
904
|
-
Q.with("inserted_users")
|
|
905
|
-
)
|
|
906
|
-
|
|
907
|
-
const insertedUsersPlan = Q.select({
|
|
908
|
-
id: insertedUsers.id,
|
|
909
|
-
email: insertedUsers.email
|
|
910
|
-
}).pipe(
|
|
911
|
-
Q.from(insertedUsers)
|
|
912
|
-
)
|
|
913
|
-
```
|
|
914
|
-
|
|
915
|
-
This is one of the places where the capability model matters: write-bearing nested plans keep write-required dialect errors in the executor error channel.
|
|
916
|
-
|
|
917
|
-
## Rendering And Execution
|
|
918
|
-
|
|
919
|
-
### Renderer
|
|
920
|
-
|
|
921
|
-
```ts
|
|
922
|
-
import { Renderer as PostgresRenderer } from "effect-qb/postgres"
|
|
923
|
-
|
|
924
|
-
const rendered = PostgresRenderer.make().render(postsPerUser)
|
|
925
|
-
|
|
926
|
-
rendered.sql
|
|
927
|
-
rendered.params
|
|
928
|
-
rendered.projections
|
|
929
|
-
```
|
|
930
|
-
|
|
931
|
-
Rendered queries carry:
|
|
932
|
-
|
|
933
|
-
- SQL text
|
|
934
|
-
- ordered bind params
|
|
935
|
-
- projection metadata
|
|
936
|
-
- the row type as a phantom type
|
|
937
|
-
|
|
938
|
-
They do not carry a query-result schema.
|
|
939
|
-
|
|
940
|
-
### Executor
|
|
941
|
-
|
|
942
|
-
```ts
|
|
943
|
-
import { Executor as PostgresExecutor, Query as Q } from "effect-qb/postgres"
|
|
944
|
-
|
|
945
|
-
const executor = PostgresExecutor.make()
|
|
946
|
-
|
|
947
|
-
const rowsEffect = executor.execute(postsPerUser)
|
|
948
|
-
|
|
949
|
-
type Rows = Q.ResultRows<typeof postsPerUser>
|
|
950
|
-
type Error = PostgresExecutor.PostgresQueryError<typeof postsPerUser>
|
|
951
|
-
```
|
|
952
|
-
|
|
953
|
-
Pass `{ renderer }`, `{ driver }`, or both when you need to customize execution.
|
|
954
|
-
|
|
955
|
-
Execution is:
|
|
956
|
-
|
|
957
|
-
1. render the plan
|
|
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
|
|
961
|
-
|
|
962
|
-
There is no automatically derived whole-query schema.
|
|
963
|
-
|
|
964
|
-
### Query-sensitive Error Channels
|
|
965
|
-
|
|
966
|
-
Dialect executors expose query-sensitive error unions:
|
|
967
|
-
|
|
968
|
-
- `Postgres.Executor.PostgresQueryError<typeof plan>`
|
|
969
|
-
- `Mysql.Executor.MysqlQueryError<typeof plan>`
|
|
970
|
-
|
|
971
|
-
Those types are narrower than the raw dialect error catalogs. For example, known write-only failures are removed from read-query error channels, while write-bearing plans retain them.
|
|
972
|
-
|
|
973
|
-
### Transaction Helpers
|
|
974
|
-
|
|
975
|
-
```ts
|
|
976
|
-
import { Executor as PostgresExecutor } from "effect-qb/postgres"
|
|
977
|
-
|
|
978
|
-
const transactional = PostgresExecutor.withTransaction(rowsEffect)
|
|
979
|
-
const savepoint = PostgresExecutor.withSavepoint(rowsEffect)
|
|
980
|
-
```
|
|
981
|
-
|
|
982
|
-
These preserve the original effect type parameters and add the ambient SQL transaction boundary.
|
|
983
|
-
|
|
984
|
-
## Error Handling
|
|
985
|
-
|
|
986
|
-
The error system does more than expose raw driver failures. It gives you:
|
|
987
|
-
|
|
988
|
-
- generated dialect catalogs for known Postgres SQLSTATEs and MySQL error symbols
|
|
989
|
-
- normalization from driver-specific wire shapes into stable tagged unions
|
|
990
|
-
- rendered query context attached to execution failures when available
|
|
991
|
-
- query-capability narrowing so read-only plans do not expose write-only failures directly
|
|
992
|
-
|
|
993
|
-
The unusual part is that these are not separate features bolted together. The built-in executors normalize every driver failure at the execution boundary, attach rendered-query context, preserve the raw payload, and then narrow the resulting error surface against the query plan capabilities. Runtime behavior and type-level behavior stay aligned.
|
|
994
|
-
|
|
995
|
-
### Catalogs And Normalization
|
|
996
|
-
|
|
997
|
-
Both dialect entrypoints expose an `Errors` module:
|
|
998
|
-
|
|
999
|
-
```ts
|
|
1000
|
-
import { Errors as PostgresErrors } from "effect-qb/postgres"
|
|
1001
|
-
import { Errors as MysqlErrors } from "effect-qb/mysql"
|
|
1002
|
-
```
|
|
1003
|
-
|
|
1004
|
-
The catalogs are backed by official vendor references:
|
|
1005
|
-
|
|
1006
|
-
- Postgres uses the SQLSTATE catalog from the current Appendix A docs
|
|
1007
|
-
- MySQL uses the official server, client, and global error references
|
|
1008
|
-
|
|
1009
|
-
That means the tags and descriptor metadata are systematic, not handwritten one-offs.
|
|
1010
|
-
|
|
1011
|
-
Postgres errors normalize around SQLSTATE codes:
|
|
1012
|
-
|
|
1013
|
-
```ts
|
|
1014
|
-
const descriptor = PostgresErrors.getPostgresErrorDescriptor("23505")
|
|
1015
|
-
descriptor.tag
|
|
1016
|
-
// "@postgres/integrity-constraint-violation/unique-violation"
|
|
1017
|
-
descriptor.classCode
|
|
1018
|
-
descriptor.className
|
|
1019
|
-
descriptor.condition
|
|
1020
|
-
descriptor.primaryFields
|
|
1021
|
-
|
|
1022
|
-
const postgresError = PostgresErrors.normalizePostgresDriverError({
|
|
1023
|
-
code: "23505",
|
|
1024
|
-
message: "duplicate key value violates unique constraint",
|
|
1025
|
-
constraint: "users_email_key"
|
|
1026
|
-
})
|
|
1027
|
-
|
|
1028
|
-
postgresError._tag
|
|
1029
|
-
postgresError.code
|
|
1030
|
-
postgresError.constraintName
|
|
1031
|
-
```
|
|
1032
|
-
|
|
1033
|
-
MySQL errors normalize around official symbols and documented numbers:
|
|
1034
|
-
|
|
1035
|
-
```ts
|
|
1036
|
-
const descriptor = MysqlErrors.getMysqlErrorDescriptor("ER_DUP_ENTRY")
|
|
1037
|
-
descriptor.tag
|
|
1038
|
-
// "@mysql/server/dup-entry"
|
|
1039
|
-
descriptor.category
|
|
1040
|
-
descriptor.number
|
|
1041
|
-
descriptor.sqlState
|
|
1042
|
-
descriptor.messageTemplate
|
|
1043
|
-
|
|
1044
|
-
const mysqlError = MysqlErrors.normalizeMysqlDriverError({
|
|
1045
|
-
code: "ER_DUP_ENTRY",
|
|
1046
|
-
errno: 1062,
|
|
1047
|
-
sqlState: "23000",
|
|
1048
|
-
sqlMessage: "Duplicate entry 'alice@example.com' for key 'users.email'"
|
|
1049
|
-
})
|
|
1050
|
-
|
|
1051
|
-
mysqlError._tag
|
|
1052
|
-
mysqlError.symbol
|
|
1053
|
-
mysqlError.number
|
|
1054
|
-
```
|
|
1055
|
-
|
|
1056
|
-
The two dialects are intentionally modeled differently:
|
|
1057
|
-
|
|
1058
|
-
- Postgres is SQLSTATE-first. Normalized errors expose `code`, `classCode`, `className`, `condition`, and the semantic fields associated with that SQLSTATE.
|
|
1059
|
-
- MySQL is symbol-first. Normalized errors expose `symbol`, `number`, `category`, `documentedSqlState`, and the official message template from the generated catalog.
|
|
1060
|
-
|
|
1061
|
-
Normalization preserves structured fields where the driver provides them. For example:
|
|
1062
|
-
|
|
1063
|
-
- Postgres surfaces fields like `detail`, `hint`, `position`, `schemaName`, `tableName`, and `constraintName`
|
|
1064
|
-
- MySQL surfaces fields like `errno`, `sqlState`, `sqlMessage`, `fatal`, `syscall`, `address`, and `port`
|
|
1065
|
-
|
|
1066
|
-
Normalized errors also preserve the original payload on `raw` for known and catalog-miss cases, so you can still reach driver-specific data without losing the stable tagged surface.
|
|
1067
|
-
|
|
1068
|
-
Unknown failures are still classified:
|
|
1069
|
-
|
|
1070
|
-
- Postgres uses `@postgres/unknown/sqlstate` for well-formed but uncataloged SQLSTATEs and `@postgres/unknown/driver` for non-Postgres failures
|
|
1071
|
-
- MySQL uses `@mysql/unknown/code` for MySQL-like catalog misses and `@mysql/unknown/driver` for non-MySQL failures
|
|
1072
|
-
|
|
1073
|
-
That fallback behavior is deliberate. Future server versions can introduce new codes without collapsing the executor back to `unknown`.
|
|
1074
|
-
|
|
1075
|
-
The normalized runtime variants are:
|
|
1076
|
-
|
|
1077
|
-
- Postgres: known SQLSTATE error, unknown SQLSTATE error, unknown driver error
|
|
1078
|
-
- MySQL: known catalog error, unknown MySQL code error, unknown driver error
|
|
1079
|
-
|
|
1080
|
-
When normalization happens during execution, the normalized error also carries `query.sql` and `query.params`.
|
|
1081
|
-
|
|
1082
|
-
One MySQL-specific detail: number lookups can be ambiguous because one documented number may correspond to multiple official symbols. The catalog API preserves that instead of guessing:
|
|
1083
|
-
|
|
1084
|
-
```ts
|
|
1085
|
-
const descriptors =
|
|
1086
|
-
Mysql.Errors.findMysqlErrorDescriptorsByNumber("MY-015144")
|
|
1087
|
-
```
|
|
1088
|
-
|
|
1089
|
-
### Query-capability Narrowing
|
|
1090
|
-
|
|
1091
|
-
Executors narrow their error channels based on what the plan is allowed to do.
|
|
1092
|
-
|
|
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.
|
|
1094
|
-
|
|
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:
|
|
1096
|
-
|
|
1097
|
-
- `@postgres/unknown/query-requirements`
|
|
1098
|
-
- `@mysql/unknown/query-requirements`
|
|
1099
|
-
|
|
1100
|
-
Those wrappers include:
|
|
1101
|
-
|
|
1102
|
-
- `requiredCapabilities`
|
|
1103
|
-
- `actualCapabilities`
|
|
1104
|
-
- `cause`
|
|
1105
|
-
- `query`
|
|
1106
|
-
|
|
1107
|
-
This makes the error channel honest about the plan you executed. A plain `select(...)` should not advertise direct unique-violation handling as though it were a write plan, even if the underlying driver returned one.
|
|
1108
|
-
|
|
1109
|
-
If the plan really is write-bearing, including write CTEs, the original normalized write error is preserved.
|
|
1110
|
-
|
|
1111
|
-
This is reflected at the type level too:
|
|
1112
|
-
|
|
1113
|
-
```ts
|
|
1114
|
-
import { Executor as PostgresExecutor } from "effect-qb/postgres"
|
|
1115
|
-
|
|
1116
|
-
type ReadError =
|
|
1117
|
-
PostgresExecutor.PostgresQueryError<typeof readPlan>
|
|
1118
|
-
|
|
1119
|
-
type WriteError =
|
|
1120
|
-
PostgresExecutor.PostgresQueryError<typeof writePlan>
|
|
1121
|
-
```
|
|
1122
|
-
|
|
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.
|
|
1124
|
-
|
|
1125
|
-
You can also inspect requirements directly:
|
|
1126
|
-
|
|
1127
|
-
```ts
|
|
1128
|
-
const postgresRequirements =
|
|
1129
|
-
PostgresErrors.requirements_of_postgres_error(postgresError)
|
|
1130
|
-
|
|
1131
|
-
const mysqlRequirements =
|
|
1132
|
-
MysqlErrors.requirements_of_mysql_error(mysqlError)
|
|
1133
|
-
```
|
|
1134
|
-
|
|
1135
|
-
### Matching Errors In Application Code
|
|
1136
|
-
|
|
1137
|
-
The executor error channel is intended to be pattern-matched, not string-parsed.
|
|
1138
|
-
|
|
1139
|
-
Use Effect tag handling for high-level branching:
|
|
1140
|
-
|
|
1141
|
-
```ts
|
|
1142
|
-
import * as Effect from "effect/Effect"
|
|
1143
|
-
|
|
1144
|
-
const rows = executor.execute(plan).pipe(
|
|
1145
|
-
Effect.catchTag("@postgres/unknown/query-requirements", (error) =>
|
|
1146
|
-
Effect.fail(error.cause)
|
|
1147
|
-
)
|
|
1148
|
-
)
|
|
1149
|
-
```
|
|
1150
|
-
|
|
1151
|
-
Use the dialect guards for precise narrowing inside shared helpers:
|
|
1152
|
-
|
|
1153
|
-
```ts
|
|
1154
|
-
if (PostgresErrors.hasSqlState(error, "23505")) {
|
|
1155
|
-
error.constraintName
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
if (MysqlErrors.hasSymbol(error, "ER_DUP_ENTRY")) {
|
|
1159
|
-
error.number
|
|
1160
|
-
}
|
|
1161
|
-
|
|
1162
|
-
if (MysqlErrors.hasNumber(error, "1062")) {
|
|
1163
|
-
error.symbol
|
|
1164
|
-
}
|
|
1165
|
-
```
|
|
1166
|
-
|
|
1167
|
-
The recommended pattern is:
|
|
1168
|
-
|
|
1169
|
-
- match `_tag` for application-level control flow
|
|
1170
|
-
- use `hasSqlState(...)`, `hasSymbol(...)`, or `hasNumber(...)` for dialect-specific detail work
|
|
1171
|
-
- fall back to `query`, `raw`, and structured fields when you need logging or translation
|
|
1172
|
-
|
|
1173
|
-
Because the tags are catalog-derived, they are stable enough to use as application error boundaries without inventing a second error taxonomy in your app.
|
|
1174
|
-
|
|
1175
|
-
In practice, the error flow is:
|
|
1176
|
-
|
|
1177
|
-
1. driver throws some unknown failure
|
|
1178
|
-
2. dialect normalizer turns it into a tagged dialect error
|
|
1179
|
-
3. executor optionally narrows it against plan capabilities
|
|
1180
|
-
4. application code matches on `_tag` or a dialect guard
|
|
1181
|
-
5. application code decides whether to recover, rethrow, or translate the failure
|
|
1182
|
-
|
|
1183
|
-
## Type Safety
|
|
1184
|
-
|
|
1185
|
-
This is the main reason to use `effect-qb`.
|
|
1186
|
-
|
|
1187
|
-
### Complete-plan Enforcement
|
|
1188
|
-
|
|
1189
|
-
Partial plans are allowed while composing, but incomplete plans fail at the enforcement boundary.
|
|
1190
|
-
|
|
1191
|
-
```ts
|
|
1192
|
-
const missingFrom = Q.select({
|
|
1193
|
-
userId: users.id
|
|
1194
|
-
})
|
|
1195
|
-
|
|
1196
|
-
type MissingFrom = Q.CompletePlan<typeof missingFrom>
|
|
1197
|
-
// {
|
|
1198
|
-
// __effect_qb_error__:
|
|
1199
|
-
// "effect-qb: query references sources that are not yet in scope"
|
|
1200
|
-
// __effect_qb_missing_sources__: "users"
|
|
1201
|
-
// __effect_qb_hint__:
|
|
1202
|
-
// "Add from(...) or a join for each referenced source before render or execute"
|
|
1203
|
-
// }
|
|
1204
|
-
```
|
|
1205
|
-
|
|
1206
|
-
The same branded error shape applies when `where(...)`, joins, or projections reference sources that never enter scope.
|
|
1207
|
-
|
|
1208
|
-
### Predicate-driven Narrowing
|
|
1209
|
-
|
|
1210
|
-
Predicates refine result types, not just SQL.
|
|
1211
|
-
|
|
1212
|
-
```ts
|
|
1213
|
-
const helloPosts = Q.select({
|
|
1214
|
-
title: posts.title,
|
|
1215
|
-
upperTitle: F.upper(posts.title)
|
|
1216
|
-
}).pipe(
|
|
1217
|
-
Q.from(posts),
|
|
1218
|
-
Q.where(Q.eq(posts.title, "hello"))
|
|
1219
|
-
)
|
|
1220
|
-
|
|
1221
|
-
type HelloPostsRow = Q.ResultRow<typeof helloPosts>
|
|
1222
|
-
// {
|
|
1223
|
-
// title: string
|
|
1224
|
-
// upperTitle: string
|
|
1225
|
-
// }
|
|
1226
|
-
```
|
|
1227
|
-
|
|
1228
|
-
Equality against a non-null literal narrows too. You do not need `isNotNull(...)` to get non-null output.
|
|
1229
|
-
|
|
1230
|
-
### Join Optionality
|
|
1231
|
-
|
|
1232
|
-
Left joins start conservative. Predicates can promote them.
|
|
1233
|
-
|
|
1234
|
-
```ts
|
|
1235
|
-
const maybePosts = Q.select({
|
|
1236
|
-
userId: users.id,
|
|
1237
|
-
postId: posts.id
|
|
1238
|
-
}).pipe(
|
|
1239
|
-
Q.from(users),
|
|
1240
|
-
Q.leftJoin(posts, Q.eq(users.id, posts.userId))
|
|
1241
|
-
)
|
|
1242
|
-
|
|
1243
|
-
type MaybePostsRow = Q.ResultRow<typeof maybePosts>
|
|
1244
|
-
// {
|
|
1245
|
-
// userId: string
|
|
1246
|
-
// postId: string | null
|
|
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
|
-
// }
|
|
1263
|
-
```
|
|
1264
|
-
|
|
1265
|
-
Any non-null proof on the joined table can promote the whole joined source, not just the join key.
|
|
1266
|
-
|
|
1267
|
-
### Grouped Query Validation
|
|
1268
|
-
|
|
1269
|
-
Grouped queries are checked structurally:
|
|
1270
|
-
|
|
1271
|
-
```ts
|
|
1272
|
-
const invalidGroupedPlan = Q.select({
|
|
1273
|
-
userId: users.id,
|
|
1274
|
-
title: posts.title,
|
|
1275
|
-
postCount: F.count(posts.id)
|
|
1276
|
-
}).pipe(
|
|
1277
|
-
Q.from(users),
|
|
1278
|
-
Q.leftJoin(posts, Q.eq(users.id, posts.userId)),
|
|
1279
|
-
Q.groupBy(users.id)
|
|
1280
|
-
)
|
|
1281
|
-
|
|
1282
|
-
type InvalidGroupedPlan = Q.CompletePlan<typeof invalidGroupedPlan>
|
|
1283
|
-
// {
|
|
1284
|
-
// __effect_qb_error__: "effect-qb: invalid grouped selection"
|
|
1285
|
-
// __effect_qb_hint__:
|
|
1286
|
-
// "Scalar selections must be covered by groupBy(...) when aggregates are present"
|
|
1287
|
-
// }
|
|
1288
|
-
```
|
|
1289
|
-
|
|
1290
|
-
This catches invalid grouped queries before rendering.
|
|
1291
|
-
|
|
1292
|
-
### Dialect Compatibility
|
|
1293
|
-
|
|
1294
|
-
Plans, tables, renderers, and executors are dialect-branded.
|
|
1295
|
-
|
|
1296
|
-
```ts
|
|
1297
|
-
import { Column as MysqlColumn, Query as MysqlQuery, Table as MysqlTable } from "effect-qb/mysql"
|
|
1298
|
-
import { Executor as PostgresExecutor } from "effect-qb/postgres"
|
|
1299
|
-
|
|
1300
|
-
const mysqlUsers = MysqlTable.make("users", {
|
|
1301
|
-
id: MysqlColumn.uuid().pipe(MysqlColumn.primaryKey)
|
|
1302
|
-
})
|
|
1303
|
-
|
|
1304
|
-
const mysqlPlan = MysqlQuery.select({
|
|
1305
|
-
id: mysqlUsers.id
|
|
1306
|
-
}).pipe(
|
|
1307
|
-
MysqlQuery.from(mysqlUsers)
|
|
1308
|
-
)
|
|
1309
|
-
|
|
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
|
|
1315
|
-
```
|
|
1316
|
-
|
|
1317
|
-
### JSON Schema Compatibility In Mutations
|
|
1318
|
-
|
|
1319
|
-
Schema-backed JSON columns are checked on insert and update.
|
|
1320
|
-
|
|
1321
|
-
```ts
|
|
1322
|
-
import * as Schema from "effect/Schema"
|
|
1323
|
-
import { Column as C, Function as F, Query as Q, Table } from "effect-qb/postgres"
|
|
1324
|
-
|
|
1325
|
-
const docs = Table.make("docs", {
|
|
1326
|
-
id: C.uuid().pipe(C.primaryKey),
|
|
1327
|
-
payload: C.json(Schema.Struct({
|
|
1328
|
-
profile: Schema.Struct({
|
|
1329
|
-
address: Schema.Struct({
|
|
1330
|
-
city: Schema.String,
|
|
1331
|
-
postcode: Schema.NullOr(Schema.String)
|
|
1332
|
-
}),
|
|
1333
|
-
tags: Schema.Array(Schema.String)
|
|
1334
|
-
}),
|
|
1335
|
-
note: Schema.NullOr(Schema.String)
|
|
1336
|
-
}))
|
|
1337
|
-
})
|
|
1338
|
-
|
|
1339
|
-
const cityPath = F.json.path(
|
|
1340
|
-
F.json.key("profile"),
|
|
1341
|
-
F.json.key("address"),
|
|
1342
|
-
F.json.key("city")
|
|
1343
|
-
)
|
|
1344
|
-
|
|
1345
|
-
const incompatibleObject = F.json.buildObject({
|
|
1346
|
-
profile: {
|
|
1347
|
-
address: {
|
|
1348
|
-
postcode: "1000"
|
|
1349
|
-
},
|
|
1350
|
-
tags: ["travel"]
|
|
1351
|
-
},
|
|
1352
|
-
note: null
|
|
1353
|
-
})
|
|
1354
|
-
|
|
1355
|
-
const deletedRequiredField = F.json.delete(docs.payload, cityPath)
|
|
1356
|
-
|
|
1357
|
-
Q.insert(docs, {
|
|
1358
|
-
id: "doc-1",
|
|
1359
|
-
// @ts-expect-error nested json output must still satisfy the column schema
|
|
1360
|
-
payload: incompatibleObject
|
|
1361
|
-
})
|
|
1362
|
-
```
|
|
1363
|
-
|
|
1364
|
-
For updates, column-derived JSON expressions are checked too:
|
|
1365
|
-
|
|
1366
|
-
```ts
|
|
1367
|
-
Q.update(docs, {
|
|
1368
|
-
// @ts-expect-error deleting a required field makes the json output incompatible
|
|
1369
|
-
payload: deletedRequiredField
|
|
1370
|
-
})
|
|
1371
|
-
```
|
|
1372
|
-
|
|
1373
|
-
The same compatibility checks apply anywhere a mutation assigns to a schema-backed JSON column.
|
|
1374
|
-
|
|
1375
|
-
### Readable Branded Type Errors
|
|
1376
|
-
|
|
1377
|
-
The library favors branded type errors over silent `never` collapse. Typical diagnostics include:
|
|
1378
|
-
|
|
1379
|
-
- `__effect_qb_error__`
|
|
1380
|
-
- `__effect_qb_hint__`
|
|
1381
|
-
- `__effect_qb_missing_sources__`
|
|
1382
|
-
- `__effect_qb_plan_dialect__`
|
|
1383
|
-
- `__effect_qb_target_dialect__`
|
|
1384
|
-
|
|
1385
|
-
That makes invalid plans easier to inspect in editor tooltips and type aliases.
|
|
1386
|
-
|
|
1387
|
-
## Dialect Support
|
|
1388
|
-
|
|
1389
|
-
### PostgreSQL
|
|
1390
|
-
|
|
1391
|
-
- `effect-qb/postgres`
|
|
1392
|
-
- `distinctOn(...)`
|
|
1393
|
-
- wider JSON operator surface, including `json.pathMatch(...)`
|
|
1394
|
-
- schema-qualified tables default to `public`
|
|
1395
|
-
- `Postgres.Executor.PostgresQueryError<typeof plan>`
|
|
1396
|
-
|
|
1397
|
-
### MySQL
|
|
1398
|
-
|
|
1399
|
-
- dialect-specific table and query entrypoint via `effect-qb/mysql`
|
|
1400
|
-
- `distinctOn(...)` is rejected with a branded type error
|
|
1401
|
-
- JSON support is broad but not identical to Postgres
|
|
1402
|
-
- schema names map to database-qualified table references
|
|
1403
|
-
- `Mysql.Executor.MysqlQueryError<typeof plan>`
|
|
1404
|
-
|
|
1405
|
-
Meaningful differences should be expected around:
|
|
1406
|
-
|
|
1407
|
-
- JSON operator support
|
|
1408
|
-
- mutation syntax
|
|
1409
|
-
- error normalization
|
|
1410
|
-
- schema defaults
|
|
1411
|
-
|
|
1412
|
-
## Limitations
|
|
1413
|
-
|
|
1414
|
-
This README is curated. It documents the main workflows and type-safety contract, not every API detail.
|
|
1415
|
-
|
|
1416
|
-
Current practical limits:
|
|
1417
|
-
|
|
1418
|
-
- some features are dialect-specific by design
|
|
1419
|
-
- JSON support is not identical between Postgres and MySQL
|
|
1420
|
-
- admin and DDL workflows are not the focus of this README
|
|
1421
|
-
- runtime schema enforcement follows propagated column and projection schemas, not full query-derived schemas
|
|
1422
|
-
|
|
1423
|
-
## Contributing
|
|
1424
|
-
|
|
1425
|
-
Useful commands:
|
|
1426
|
-
|
|
1427
|
-
```bash
|
|
1428
|
-
bun test
|
|
1429
|
-
bun run test:types
|
|
1430
|
-
bun run test:integration
|
|
1431
|
-
bun run release
|
|
1432
|
-
```
|
|
1433
|
-
|
|
1434
|
-
Useful places to start:
|
|
1435
|
-
|
|
1436
|
-
- [src/postgres.ts](./src/postgres.ts)
|
|
1437
|
-
- [src/internal/query-factory.ts](./src/internal/query-factory.ts)
|
|
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)
|
|
1441
|
-
|
|
1442
|
-
The codebase is organized around typed plans, dialect-specialized entrypoints, and behavior-first tests.
|
|
17
|
+
`effect-qb/postgres/metadata` exposes normalized table and enum metadata helpers used by `effectdb`.
|