Skip to content

feat(postgres): typed jsonb paths, @> and jsonb_set from the column schema - #115

Merged
Makisuo merged 6 commits into
MapleTechLabs:mainfrom
artile:feat/json-paths
Nov 2, 2026
Merged

Makisuo merged 6 commits into
MapleTechLabs:mainfrom
artile:feat/json-paths

Conversation

@artile

@artile artile commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

A jsonb column knows its document schema, but a query cannot read, filter or write one field of it with types. Today a path read is untyped text (jsonText), and a containment filter or a one-path update needs a raw CH.sql fragment.

Closes #84.
Part of #52.

Changes

  • PG.jsonPath(column, ...keys) reads a path with ->. The keys and the result type come from the column's TypeScript type, and the decoder comes from the column's Schema: jsonPath($.payload, "profile", "city") is an Expr<string | null>. A number key indexes an array. A missing key reads as null.
  • A value compared with a path is encoded by the field's schema and bound as JSON text. .eq("Kyiv") matches the JSON string "Kyiv".
  • PG.jsonPathText(column, ...keys) reads a path as text with ->>, for like and text comparisons.
  • PG.jsonContains(expr, part) writes (expr @> $n::jsonb). part is a deep partial of the document type. It also takes a jsonPath.
  • PG.jsonSet(expr, keys, value) writes jsonb_set(expr, ARRAY[...], $n::jsonb). Use it in an UPDATE set to write one path without a read-modify-write. The value has the path's type and is encoded by the field's schema. Nest calls to set more than one path. RETURNING decodes the full document.
  • The dialect-neutral part is in src/ch/json-path.ts: the path types (JsonPath, JsonPathValue, JsonContained, JsonKeyOf, JsonStep, JsonKey) and jsonPathSchema. jsonPathSchema walks the Schema AST through structs, records, arrays, tuples, unions, NullOr, optional keys and suspend. Only the SQL is in src/pg/functions.ts. These are exported from /postgres for helpers.
  • A path the schema does not have is a type error and a QueryBuilderDefect at compile. A path through a node whose stored form differs from its type (Schema.encodeKeys, a class) is refused, because the stored keys can differ. A value with no JSON form (a bigint) fails with QueryBuilderError InvalidLiteral, not a throw.
  • Keys are SQL literals, not bound params, so an expression index on (payload -> 'profile') matches.
  • Docs: a "JSON paths" section in docs/postgres.md with a PGlite example that check-doc-examples runs, docs/reference.md, a design/dialects.md note, and the P0 row in design/gap-review.md.
  • A minor changeset.

How SQLite and MySQL plug in: a SQLite or MySQL catalog adds its own jsonPath / jsonSet / jsonContains. They reuse the same path types and jsonPathSchema, and write json_extract(x, '$.a[0]') and json_set(x, '$.a', json(?)) on SQLite, or JSON_EXTRACT, JSON_UNQUOTE, JSON_CONTAINS and JSON_SET on MySQL. The only new piece is a function that writes the keys as a $.a[0] path string.

Left out

  • SQLite and MySQL rendering. Upstream main has no SQLite or MySQL dialect yet (SQLite is feat: add SQLite and Cloudflare D1 query dialects #49). The neutral module is ready for them, as described above.
  • The ?, ?|, ?& key-exists operators, and the array operators && and ANY. They are the other part of the gap-review P0 row. A follow-up can add them on the same base.
  • A property-access proxy ($.payload.profile.city, as in effect-qb). A proxy on a column ref collides with Expr method names (eq, get, in_) and with document keys that have those names. Variadic keys give the same types and autocompletion without that risk.
  • jsonContains does not encode its value through the schema, because a partial document does not satisfy a full schema. The value is written as given (the stored form). This is documented.
  • A param or expression as the value of jsonContains / jsonSet. CH.sql still covers that case.
  • jsonb_set's create_missing argument, #> / #>>, and jsonb_insert.

Validation

  • bun run build: pass.
  • bun run typecheck: pass (includes the new src/pg/json.test-d.ts).
  • bunx vitest run --testTimeout=60000 --hookTimeout=60000 with the live ClickHouse 26.8.2.7 env set: 61 files passed, 1046 tests passed, 0 skipped. The new src/pg/json.test.ts runs every query on PGlite. The postgres coverage manifest has a json-paths case.
  • node scripts/check-doc-citations.mjs: ok. node scripts/check-exports-documented.mjs: ok (217 /postgres exports). node scripts/check-doc-examples.mjs: ok (22 files type-checked; the new example runs on PGlite).
  • bun scripts/check-package.ts: ok.
  • Upstream CI for a fork PR waits until a maintainer approves the run.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 43 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 11ef4b67-2c1d-4e98-af42-b9c1fd3a478f
Commits

Reviewing files that changed from the base of the PR and between b57faf1 and 5c1a336.

Files selected for processing: 12
  • .changeset/pg-json-paths.md
  • design/dialects.md
  • design/gap-review.md
  • docs/postgres.md
  • docs/reference.md
  • scripts/check-doc-examples.mjs
  • src/ch/json-path.ts
  • src/pg/functions.ts
  • src/pg/json.test-d.ts
  • src/pg/json.test.ts
  • src/postgres.ts
  • tests/dialect-cases.postgres.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

Makisuo and others added 2 commits November 2, 2026 16:37
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Makisuo

Makisuo commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Brought up to date for merge (head 5c1a336):

  • Merged origin/main. No conflicts. Build and typecheck pass against Effect 4.0.6 and the new dialects with no code changes.
  • SQLite/D1 behaviour: these are Postgres built-ins, so the existing function-set guard already refuses them under another dialect. json.test.ts now asserts that jsonPath, jsonSet and jsonContains compiled for sqliteDialect and d1Dialect die with "… is a Postgres function … no meaning for the sqlite/d1 dialect" rather than sending -> / @> / jsonb_set SQL.
  • The SQLite gap is now written down: docs/postgres.md#json-paths and the changeset say this is Postgres-only. SQLite/D1 have no typed JSON paths yet; a SQLite.json(schema) column is read and written whole. The part of Typed JSON paths and JSON updates on SQLite and MySQL #84 that asks for SQLite/MySQL is still open. src/ch/json-path.ts (path types + jsonPathSchema) is the base a SQLite catalog would reuse.

Verified locally: bun run build, bun run typecheck, and bun run test against a live ClickHouse 26.8 (74 files / 1442 tests, plus the doc checks, including the PGlite postgres-json-paths doc example) all pass. FUZZ_SCALE=0.3 vitest run tests/fuzz: 268/269. The one failure was "generated fixtures: union results agree (clickhouse-join-nulls)", a ClickHouse "Division by zero" on an empty constant fixture. That is the known flake fixed in #131.

CI run 41473731791: 26.8 passed. On 26.2 the only failure was "generated fixtures: query results agree (clickhouse-join-nulls)", the same "Division by zero" flake fixed in #131. It is unrelated to this PR, which touches no ClickHouse code.

Left for the maintainer: jsonContains(x, part) is typed against the decoded document type, but its value is written as given, in stored form. A NumberFromString field therefore type-checks as { visits: 3 } yet only matches when written "3". This is documented, but the type and the runtime disagree for transforming fields.

@Makisuo
Makisuo merged commit 5fa95ef into MapleTechLabs:main Nov 2, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Typed JSON paths and JSON updates on SQLite and MySQL

2 participants