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.
Files changed (54) hide show
  1. package/README.md +6 -1431
  2. package/dist/mysql.js +1678 -355
  3. package/dist/postgres/metadata.js +2724 -0
  4. package/dist/postgres.js +7197 -5433
  5. package/package.json +8 -10
  6. package/src/internal/column-state.ts +84 -10
  7. package/src/internal/column.ts +556 -34
  8. package/src/internal/datatypes/define.ts +0 -30
  9. package/src/internal/executor.ts +45 -11
  10. package/src/internal/expression-ast.ts +4 -0
  11. package/src/internal/expression.ts +1 -1
  12. package/src/internal/implication-runtime.ts +171 -0
  13. package/src/internal/mysql-query.ts +7173 -0
  14. package/src/internal/mysql-renderer.ts +2 -2
  15. package/src/internal/plan.ts +14 -4
  16. package/src/internal/{query-factory.ts → postgres-query.ts} +619 -167
  17. package/src/internal/postgres-renderer.ts +2 -2
  18. package/src/internal/postgres-schema-model.ts +144 -0
  19. package/src/internal/predicate-analysis.ts +10 -0
  20. package/src/internal/predicate-context.ts +112 -36
  21. package/src/internal/predicate-formula.ts +31 -19
  22. package/src/internal/predicate-normalize.ts +177 -106
  23. package/src/internal/predicate-runtime.ts +676 -0
  24. package/src/internal/query.ts +455 -39
  25. package/src/internal/renderer.ts +2 -2
  26. package/src/internal/runtime-schema.ts +74 -20
  27. package/src/internal/schema-ddl.ts +55 -0
  28. package/src/internal/schema-derivation.ts +93 -21
  29. package/src/internal/schema-expression.ts +44 -0
  30. package/src/internal/sql-expression-renderer.ts +95 -31
  31. package/src/internal/table-options.ts +87 -7
  32. package/src/internal/table.ts +104 -41
  33. package/src/mysql/column.ts +1 -0
  34. package/src/mysql/datatypes/index.ts +17 -2
  35. package/src/mysql/function/core.ts +1 -0
  36. package/src/mysql/function/index.ts +1 -0
  37. package/src/mysql/private/query.ts +1 -13
  38. package/src/mysql/query.ts +5 -0
  39. package/src/postgres/cast.ts +31 -0
  40. package/src/postgres/column.ts +26 -0
  41. package/src/postgres/datatypes/index.ts +40 -5
  42. package/src/postgres/function/core.ts +12 -0
  43. package/src/postgres/function/index.ts +2 -1
  44. package/src/postgres/function/json.ts +499 -2
  45. package/src/postgres/metadata.ts +31 -0
  46. package/src/postgres/private/query.ts +1 -13
  47. package/src/postgres/query.ts +5 -2
  48. package/src/postgres/schema-expression.ts +16 -0
  49. package/src/postgres/schema-management.ts +204 -0
  50. package/src/postgres/schema.ts +35 -0
  51. package/src/postgres/table.ts +307 -41
  52. package/src/postgres/type.ts +4 -0
  53. package/src/postgres.ts +14 -0
  54. package/CHANGELOG.md +0 -134
package/README.md CHANGED
@@ -1,1442 +1,17 @@
1
1
  # effect-qb
2
2
 
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.
3
+ `effect-qb` is the typed SQL querybuilder package in this workspace.
4
4
 
5
- ## Overview
5
+ ## Install
6
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
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
- 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
-
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
- Use `effect-qb/postgres` when you want explicit Postgres branding throughout the plan, renderer, executor, datatypes, and errors.
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`.