Skip to content

BE-306: HashQL: PostgreSQL translation#8526

Open
indietyp wants to merge 7 commits intobm/be-457-hashql-mir-execution-pipeline-extensions-for-postgresfrom
bm/be-306-hashql-postgres-translation
Open

BE-306: HashQL: PostgreSQL translation#8526
indietyp wants to merge 7 commits intobm/be-457-hashql-mir-execution-pipeline-extensions-for-postgresfrom
bm/be-306-hashql-postgres-translation

Conversation

@indietyp
Copy link
Copy Markdown
Member

@indietyp indietyp commented Mar 8, 2026

🌟 What is the purpose of this PR?

Implements the postgres compilation backend for HashQL. Takes the MIR control flow graph (after execution analysis has assigned basic blocks to backends and partitioned them into islands) and compiles the Postgres-assigned islands into SQL SELECT statements.

🔍 What does this change?

Postgres compiler (eval/src/postgres/mod.rs):
Top-level entry point. Compiles a GraphRead body island-by-island into a PreparedQuery (a SelectStatement + deduplicated Parameters list). Each Postgres island becomes a CROSS JOIN LATERAL subquery returning a continuation composite value. The continuation carries filter (keep/reject/passthrough), block (next basic block), and locals/values (live-out data for the interpreter to resume from).

Filter compiler (eval/src/postgres/filter/):
Walks the MIR basic blocks within an island and compiles each statement into SQL expressions. Uses an explicit frame stack (not recursion) to handle SwitchInt terminators: each branch becomes a CASE WHEN arm, with the discriminant cast to ::int to avoid boolean/integer type mismatches in PostgreSQL. Out-of-island branches produce continuation values that encode which block to resume and what locals to carry.

Projections (eval/src/postgres/projections.rs):
Maps EntityPath variants to SQL column references or JSONB extraction expressions. Tracks which table joins are needed and only requests them when a path is actually referenced. Handles the split between "column-backed" paths (entity_uuid, web_id, etc.) and "JSONB-backed" paths (properties, type IDs).

Parameters (eval/src/postgres/parameters.rs):
Builds the $1, $2, ... parameter list for the prepared statement. Deduplicates by identity. Each Parameter variant represents a different source: Input (user-provided values), Symbol/Primitive/Int (query literals), Env (closure captures), TemporalAxis (execution context). The CompiledQuery return type exposes which indices correspond to which sources so the interpreter can bind them.

Continuation (eval/src/postgres/continuation.rs):
Builds the ROW(filter, block, locals, values)::continuation composite values that encode island exit state. Handles the three exit cases: passthrough (NULL continuation), filter-only (just a boolean), and full exit (block + live-out locals serialized as parallel int[]/jsonb[] arrays).

Traverse (eval/src/postgres/traverse.rs):
Compiles graph traversal requirements into SQL joins. Reads the island's provides set to determine which entity paths need table joins, then requests them from the database context layer.

Error infrastructure (eval/src/postgres/error.rs):
Diagnostic types for compilation errors (unsupported operations, type mismatches, missing paths) with span-accurate source locations.

Context (eval/src/context.rs):
DatabaseContext trait and implementation that the compiler uses to request table aliases, register joins, and access the schema. Bridges between the HashQL type system and the graph-store query builder.

Compiletest suite (compiletest/src/suite/eval_postgres.rs):
New compiletest suite that runs the full pipeline (parse, type-check, lower to MIR, run execution analysis, compile to SQL) and compares the output against blessed .stdout files. Also emits .aux.mir secondary outputs showing the MIR after execution analysis for debugging.

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • does not modify any publishable blocks or libraries, or modifications do not need publishing

📜 Does this require a change to the docs?

The changes in this PR:

  • are internal and do not require a docs change

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • do not affect the execution graph

⚠️ Known issues

OFFSET 0 on lateral subqueries is a workaround for PostgreSQL inlining composites; see continuation.rs doc comments for details.

🛡 What tests cover this?

  • Filter compiler unit tests (filter/tests.rs, ~1000 lines) using insta snapshots covering: straight-line blocks, branching CFGs, diamond merges, island exits, projections, property access, parameter deduplication, lateral subquery generation
  • 30+ compiletest cases under eval/tests/ui/postgres/ covering end-to-end compilation: comparison operators, entity field access, input parameters, let bindings, if-expressions, nested branching, environment captures, list/dict/struct/tuple construction, multiple filters, mixed-source filters

❓ How to test this?

cargo nextest run -p hashql-eval
cargo run --package hashql-compiletest -- run

indietyp added 2 commits March 8, 2026 16:17
feat: checkpoint (II)

feat: checkpoint (III)

feat: snapshot vec

feat: add dedicated filter

feat: checkpoint

feat: filter implementation

feat: filter implementation (mostly) done

chore: environment capture note

chore: always postgres bigint

feat: target clone

feat: simplify lookup

feat: move storage up

feat: eval entity path

chore: checkpoint

chore: checkpoint

chore: find entrypoint

feat: eval context

feat: eval cleanup

chore: cleanup

feat: track index

feat: wire up filter

feat: add error reporting

chore: checkpoint

feat: add traverse, and first postgres compiler outline

feat: traverse bitmap

feat: move traversal out

feat: projections

feat: projections

fix: clippy

feat: subquery projection for lateral

feat: checkpoint

feat: test plan

feat: checkpoint

feat: checkpoint – failing tests ;-;

feat: checkpoint – failing tests ;-;

feat: checkpoint — passing tests

fix: import

fix: entity type

feat: checkpoint

feat: attribute a cost to terminator placement switches

fix: import

feat: checkpoint

feat: checkpoint

chore: lint
@cursor
Copy link
Copy Markdown

cursor bot commented Mar 8, 2026

PR Summary

High Risk
Adds a new HashQL MIR→PostgreSQL compiler that generates SQL with lateral subqueries, parameters, and schema joins; correctness and performance issues could affect query results and database load. Touches query-building types (e.g. adds boolean Postgres type) and introduces substantial new logic and tests.

Overview
Implements a PostgreSQL compilation backend for HashQL by lowering MIR GraphRead filter bodies into a single SELECT with CROSS JOIN LATERAL continuation subqueries and a deduplicated $N parameter catalog.

Adds new evaluation infrastructure (EvalContext live-out analysis, Postgres diagnostics, continuation naming/field access, lazy join/projection planning, and EntityPath→SQL mapping) plus a new compiletest suite (eval/postgres) and extensive snapshot/UI tests validating SQL output and MIR annotations.

Also extends the Postgres query AST with a PostgresType::Boolean and wires hashql-eval to depend on hash-graph-postgres-store/hashql-mir (and insta for tests), updating generated dependency diagrams/lockfile accordingly.

Written by Cursor Bugbot for commit 1024ba5. This will update automatically on new commits. Configure here.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hash Ready Ready Preview, Comment Mar 20, 2026 8:25am
petrinaut Ready Ready Preview Mar 20, 2026 8:25am
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
hashdotdesign Ignored Ignored Preview Mar 20, 2026 8:25am
hashdotdesign-tokens Ignored Ignored Preview Mar 20, 2026 8:25am

@github-actions github-actions bot removed the area/infra Relates to version control, CI, CD or IaC (area) label Mar 8, 2026
@augmentcode
Copy link
Copy Markdown

augmentcode bot commented Mar 8, 2026

🤖 Augment PR Summary

Summary: Adds a PostgreSQL compilation backend for HashQL by lowering MIR execution islands into SQL SELECT statements with continuation-based control flow.

Key changes:

  • Introduces eval/src/postgres/ with a top-level PostgresCompiler that compiles GraphRead filters island-by-island into lateral subqueries returning a typed continuation composite.
  • Adds a CFG-to-SQL filter compiler (postgres/filter) that builds CASE WHEN trees iteratively (explicit frame stack) and emits island-exit continuations carrying live-out locals.
  • Implements parameter deduplication (postgres/parameters.rs) and lazy join planning/projection mapping (postgres/projections.rs, postgres/traverse.rs).
  • Adds new eval-level EvalContext + live-out dataflow computation used by the Postgres backend.
  • Extends diagnostics to include Postgres backend categories (postgres/error.rs + eval/src/error.rs integration).
  • Adds a new compiletest suite (compiletest/src/suite/eval_postgres.rs) and a sizable set of end-to-end UI test fixtures under eval/tests/ui/postgres/, plus MIR snapshot tests for hard-to-produce MIR shapes.

Technical notes: Compiled filter islands are materialized with CROSS JOIN LATERAL and OFFSET 0 to prevent Postgres from inlining/duplicating CASE trees; selected entity paths are driven by the island provides set and only trigger joins when referenced.

🤖 Was this summary useful? React with 👍 or 👎

Copy link
Copy Markdown

@augmentcode augmentcode bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review completed. 2 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Mar 8, 2026

Merging this PR will not alter performance

✅ 80 untouched benchmarks


Comparing bm/be-306-hashql-postgres-translation (1024ba5) with bm/be-457-hashql-mir-execution-pipeline-extensions-for-postgres (c67124e)

Open in CodSpeed

@indietyp indietyp force-pushed the bm/be-306-hashql-postgres-translation branch from 33a9dcc to 8c07a05 Compare March 8, 2026 16:15
@indietyp indietyp force-pushed the bm/be-457-hashql-mir-execution-pipeline-extensions-for-postgres branch from 5d95ba7 to 1aa0f1c Compare March 8, 2026 16:15
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 8, 2026

Codecov Report

❌ Patch coverage is 94.55704% with 94 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.94%. Comparing base (c67124e) to head (1024ba5).

Files with missing lines Patch % Lines
libs/@local/hashql/eval/src/postgres/filter/mod.rs 89.30% 43 Missing and 5 partials ⚠️
libs/@local/hashql/eval/src/postgres/error.rs 0.00% 22 Missing ⚠️
...bs/@local/hashql/eval/src/postgres/filter/tests.rs 98.22% 5 Missing and 3 partials ⚠️
libs/@local/hashql/eval/src/postgres/mod.rs 96.95% 4 Missing and 1 partial ⚠️
libs/@local/hashql/eval/src/postgres/traverse.rs 96.00% 5 Missing ⚠️
libs/@local/hashql/eval/src/context.rs 95.55% 1 Missing and 1 partial ⚠️
libs/@local/hashql/eval/src/postgres/parameters.rs 98.26% 2 Missing ⚠️
...src/store/postgres/query/expression/conditional.rs 0.00% 1 Missing ⚠️
libs/@local/hashql/eval/src/error.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@                                         Coverage Diff                                         @@
##           bm/be-457-hashql-mir-execution-pipeline-extensions-for-postgres    #8526      +/-   ##
===================================================================================================
- Coverage                                                            72.02%   63.94%   -8.09%     
===================================================================================================
  Files                                                                  785     1195     +410     
  Lines                                                                71140   130697   +59557     
  Branches                                                              3868     5005    +1137     
===================================================================================================
+ Hits                                                                 51242    83570   +32328     
- Misses                                                               19392    46260   +26868     
- Partials                                                               506      867     +361     
Flag Coverage Δ
apps.hash-ai-worker-ts 1.40% <ø> (ø)
apps.hash-api 0.00% <ø> (ø)
blockprotocol.type-system 40.84% <ø> (?)
local.claude-hooks 0.00% <ø> (?)
local.harpc-client 51.24% <ø> (?)
local.hash-graph-sdk 9.63% <ø> (ø)
local.hash-isomorphic-utils 0.00% <ø> (ø)
rust.antsi 0.00% <ø> (?)
rust.error-stack 90.87% <ø> (?)
rust.harpc-codec 84.70% <ø> (?)
rust.harpc-net 96.18% <ø> (?)
rust.harpc-tower 67.03% <ø> (?)
rust.harpc-types 0.00% <ø> (?)
rust.harpc-wire-protocol 92.23% <ø> (?)
rust.hash-codec 72.76% <ø> (?)
rust.hash-graph-api 2.52% <ø> (?)
rust.hash-graph-authorization 62.34% <ø> (?)
rust.hash-graph-postgres-store 26.81% <0.00%> (?)
rust.hash-graph-store 37.76% <ø> (?)
rust.hash-graph-temporal-versioning 47.95% <ø> (?)
rust.hash-graph-types 0.00% <ø> (?)
rust.hash-graph-validation 83.45% <ø> (?)
rust.hashql-ast 87.23% <ø> (?)
rust.hashql-compiletest 29.69% <ø> (ø)
rust.hashql-core 82.44% <ø> (ø)
rust.hashql-diagnostics 72.43% <ø> (?)
rust.hashql-eval 80.88% <94.61%> (?)
rust.hashql-hir 89.06% <ø> (ø)
rust.hashql-mir 92.08% <ø> (ø)
rust.hashql-syntax-jexpr 94.05% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@graphite-app graphite-app bot requested review from a team March 9, 2026 09:43
@@ -0,0 +1,7 @@
---
source: libs/@local/hashql/eval/src/postgres/filter/tests.rs
expression: report.to_string()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we please change this expression to the actual query? Then it's far easier to review the tests.

@github-actions
Copy link
Copy Markdown
Contributor

Benchmark results

@rust/hash-graph-benches – Integrations

policy_resolution_large

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 2002 $$27.1 \mathrm{ms} \pm 188 \mathrm{μs}\left({\color{gray}-4.834 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$3.55 \mathrm{ms} \pm 24.2 \mathrm{μs}\left({\color{gray}-1.717 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 1001 $$12.4 \mathrm{ms} \pm 70.3 \mathrm{μs}\left({\color{lightgreen}-9.900 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 3314 $$43.6 \mathrm{ms} \pm 320 \mathrm{μs}\left({\color{gray}-3.213 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$15.0 \mathrm{ms} \pm 118 \mathrm{μs}\left({\color{lightgreen}-6.471 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 1526 $$24.2 \mathrm{ms} \pm 170 \mathrm{μs}\left({\color{lightgreen}-5.044 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 2078 $$28.4 \mathrm{ms} \pm 176 \mathrm{μs}\left({\color{gray}-1.932 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.80 \mathrm{ms} \pm 19.6 \mathrm{μs}\left({\color{gray}-1.816 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 1033 $$13.4 \mathrm{ms} \pm 114 \mathrm{μs}\left({\color{lightgreen}-7.267 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_medium

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 102 $$3.91 \mathrm{ms} \pm 18.7 \mathrm{μs}\left({\color{gray}-1.652 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$3.09 \mathrm{ms} \pm 15.8 \mathrm{μs}\left({\color{gray}-1.274 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 51 $$3.44 \mathrm{ms} \pm 14.6 \mathrm{μs}\left({\color{gray}-2.538 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 269 $$5.35 \mathrm{ms} \pm 27.0 \mathrm{μs}\left({\color{gray}-1.005 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$3.68 \mathrm{ms} \pm 15.5 \mathrm{μs}\left({\color{gray}-1.108 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 107 $$4.28 \mathrm{ms} \pm 20.0 \mathrm{μs}\left({\color{gray}-1.131 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 133 $$4.56 \mathrm{ms} \pm 24.1 \mathrm{μs}\left({\color{gray}-1.614 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.55 \mathrm{ms} \pm 17.8 \mathrm{μs}\left({\color{gray}-1.305 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 63 $$4.22 \mathrm{ms} \pm 26.9 \mathrm{μs}\left({\color{gray}-0.579 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_none

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 2 $$2.83 \mathrm{ms} \pm 17.3 \mathrm{μs}\left({\color{gray}-2.677 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.77 \mathrm{ms} \pm 13.6 \mathrm{μs}\left({\color{gray}-1.169 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 1 $$2.91 \mathrm{ms} \pm 16.3 \mathrm{μs}\left({\color{gray}-1.238 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 8 $$3.15 \mathrm{ms} \pm 15.9 \mathrm{μs}\left({\color{gray}-1.760 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$2.98 \mathrm{ms} \pm 20.6 \mathrm{μs}\left({\color{gray}-1.437 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 3 $$3.25 \mathrm{ms} \pm 15.5 \mathrm{μs}\left({\color{gray}-3.407 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_small

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 52 $$3.18 \mathrm{ms} \pm 17.9 \mathrm{μs}\left({\color{gray}-1.788 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.92 \mathrm{ms} \pm 17.3 \mathrm{μs}\left({\color{gray}-1.122 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 25 $$3.06 \mathrm{ms} \pm 18.4 \mathrm{μs}\left({\color{gray}-1.449 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 94 $$3.65 \mathrm{ms} \pm 20.4 \mathrm{μs}\left({\color{gray}-1.036 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$3.16 \mathrm{ms} \pm 13.6 \mathrm{μs}\left({\color{gray}-2.350 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 26 $$3.41 \mathrm{ms} \pm 16.1 \mathrm{μs}\left({\color{gray}-1.700 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 66 $$3.56 \mathrm{ms} \pm 24.1 \mathrm{μs}\left({\color{gray}-2.179 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.18 \mathrm{ms} \pm 21.5 \mathrm{μs}\left({\color{gray}-1.538 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 29 $$3.44 \mathrm{ms} \pm 18.5 \mathrm{μs}\left({\color{gray}-2.495 \mathrm{\%}}\right) $$ Flame Graph

read_scaling_complete

Function Value Mean Flame graphs
entity_by_id;one_depth 1 entities $$46.1 \mathrm{ms} \pm 198 \mathrm{μs}\left({\color{gray}-0.732 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 10 entities $$65.6 \mathrm{ms} \pm 311 \mathrm{μs}\left({\color{lightgreen}-22.952 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 25 entities $$50.2 \mathrm{ms} \pm 274 \mathrm{μs}\left({\color{gray}-1.713 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 5 entities $$53.4 \mathrm{ms} \pm 332 \mathrm{μs}\left({\color{gray}-0.627 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 50 entities $$63.5 \mathrm{ms} \pm 346 \mathrm{μs}\left({\color{gray}-2.218 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 1 entities $$48.0 \mathrm{ms} \pm 248 \mathrm{μs}\left({\color{gray}0.017 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 10 entities $$252 \mathrm{ms} \pm 712 \mathrm{μs}\left({\color{lightgreen}-41.067 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 25 entities $$103 \mathrm{ms} \pm 518 \mathrm{μs}\left({\color{gray}2.80 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 5 entities $$92.4 \mathrm{ms} \pm 360 \mathrm{μs}\left({\color{gray}-0.843 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 50 entities $$291 \mathrm{ms} \pm 855 \mathrm{μs}\left({\color{gray}-2.117 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 1 entities $$19.7 \mathrm{ms} \pm 113 \mathrm{μs}\left({\color{gray}-4.435 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 10 entities $$20.6 \mathrm{ms} \pm 130 \mathrm{μs}\left({\color{gray}-3.847 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 25 entities $$20.7 \mathrm{ms} \pm 151 \mathrm{μs}\left({\color{gray}-3.208 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 5 entities $$20.4 \mathrm{ms} \pm 96.9 \mathrm{μs}\left({\color{gray}-4.284 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 50 entities $$24.7 \mathrm{ms} \pm 228 \mathrm{μs}\left({\color{gray}-4.015 \mathrm{\%}}\right) $$ Flame Graph

read_scaling_linkless

Function Value Mean Flame graphs
entity_by_id 1 entities $$19.9 \mathrm{ms} \pm 102 \mathrm{μs}\left({\color{gray}-4.337 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 10 entities $$19.8 \mathrm{ms} \pm 100 \mathrm{μs}\left({\color{gray}-3.272 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 100 entities $$20.0 \mathrm{ms} \pm 126 \mathrm{μs}\left({\color{gray}-1.391 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 1000 entities $$20.4 \mathrm{ms} \pm 111 \mathrm{μs}\left({\color{gray}-1.711 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 10000 entities $$26.9 \mathrm{ms} \pm 234 \mathrm{μs}\left({\color{gray}-2.735 \mathrm{\%}}\right) $$ Flame Graph

representative_read_entity

Function Value Mean Flame graphs
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/block/v/1 $$34.2 \mathrm{ms} \pm 329 \mathrm{μs}\left({\color{gray}-4.616 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/book/v/1 $$35.0 \mathrm{ms} \pm 280 \mathrm{μs}\left({\color{gray}-4.831 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/building/v/1 $$36.0 \mathrm{ms} \pm 268 \mathrm{μs}\left({\color{gray}-2.949 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/organization/v/1 $$35.1 \mathrm{ms} \pm 264 \mathrm{μs}\left({\color{gray}-0.453 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/page/v/2 $$35.6 \mathrm{ms} \pm 284 \mathrm{μs}\left({\color{gray}-2.865 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/person/v/1 $$34.2 \mathrm{ms} \pm 298 \mathrm{μs}\left({\color{gray}-2.084 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/playlist/v/1 $$34.7 \mathrm{ms} \pm 297 \mathrm{μs}\left({\color{gray}-2.189 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/song/v/1 $$36.1 \mathrm{ms} \pm 280 \mathrm{μs}\left({\color{gray}0.570 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/uk-address/v/1 $$35.1 \mathrm{ms} \pm 248 \mathrm{μs}\left({\color{gray}-0.261 \mathrm{\%}}\right) $$ Flame Graph

representative_read_entity_type

Function Value Mean Flame graphs
get_entity_type_by_id Account ID: bf5a9ef5-dc3b-43cf-a291-6210c0321eba $$8.87 \mathrm{ms} \pm 49.6 \mathrm{μs}\left({\color{gray}2.10 \mathrm{\%}}\right) $$ Flame Graph

representative_read_multiple_entities

Function Value Mean Flame graphs
entity_by_property traversal_paths=0 0 $$98.5 \mathrm{ms} \pm 538 \mathrm{μs}\left({\color{red}5.74 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=255 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true $$150 \mathrm{ms} \pm 671 \mathrm{μs}\left({\color{gray}1.94 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false $$105 \mathrm{ms} \pm 482 \mathrm{μs}\left({\color{gray}3.47 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true $$115 \mathrm{ms} \pm 460 \mathrm{μs}\left({\color{red}6.28 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true $$123 \mathrm{ms} \pm 708 \mathrm{μs}\left({\color{gray}4.85 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true $$130 \mathrm{ms} \pm 755 \mathrm{μs}\left({\color{gray}2.86 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=0 0 $$106 \mathrm{ms} \pm 637 \mathrm{μs}\left({\color{gray}4.09 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=255 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true $$130 \mathrm{ms} \pm 491 \mathrm{μs}\left({\color{gray}-0.899 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false $$111 \mathrm{ms} \pm 495 \mathrm{μs}\left({\color{gray}1.52 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true $$117 \mathrm{ms} \pm 562 \mathrm{μs}\left({\color{gray}-1.279 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true $$119 \mathrm{ms} \pm 470 \mathrm{μs}\left({\color{gray}-0.156 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true $$119 \mathrm{ms} \pm 479 \mathrm{μs}\left({\color{gray}-0.587 \mathrm{\%}}\right) $$

scenarios

Function Value Mean Flame graphs
full_test query-limited $$135 \mathrm{ms} \pm 524 \mathrm{μs}\left({\color{gray}-1.171 \mathrm{\%}}\right) $$ Flame Graph
full_test query-unlimited $$147 \mathrm{ms} \pm 534 \mathrm{μs}\left({\color{gray}-1.401 \mathrm{\%}}\right) $$ Flame Graph
linked_queries query-limited $$106 \mathrm{ms} \pm 625 \mathrm{μs}\left({\color{gray}-2.532 \mathrm{\%}}\right) $$ Flame Graph
linked_queries query-unlimited $$541 \mathrm{ms} \pm 930 \mathrm{μs}\left({\color{lightgreen}-7.944 \mathrm{\%}}\right) $$ Flame Graph

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 31, 2026

Deployment failed with the following error:

Invalid request: `attribution.gitUser` should NOT have additional property `isBot`.

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for all 4 issues found in the latest run.

  • ✅ Fixed: Null filters are treated as matches
    • Return continuations now normalize filter expressions to reject SQL NULL so only true boolean predicate results are treated as matches.
  • ✅ Fixed: Switch discriminant forced to 32-bit int
    • SwitchInt comparison now widens to numeric when case values exceed 32-bit range, preventing truncation/overflow miscomparisons for wide switch targets.
  • ✅ Fixed: Island exits can panic on captured environment
    • Island-exit live-out serialization now skips Local::ENV so captured environment access no longer tries to read a non-materialized local slot.
  • ✅ Fixed: Non-entry Postgres islands miss parameter binding
    • compile_body now seeds non-start island entry parameters from incoming external edge targets before compiling the entry block.

Create PR

Or push these changes by commenting:

@cursor push 98e8893a92
Preview (98e8893a92)
diff --git a/libs/@local/hashql/eval/src/postgres/filter/mod.rs b/libs/@local/hashql/eval/src/postgres/filter/mod.rs
--- a/libs/@local/hashql/eval/src/postgres/filter/mod.rs
+++ b/libs/@local/hashql/eval/src/postgres/filter/mod.rs
@@ -95,8 +95,24 @@
         // (filter, block, locals, values)
         let row = match continuation {
             Continuation::Return { filter } => {
+                // Normalize SQL three-valued logic to two-valued filter semantics:
+                // `NULL` from predicate evaluation should behave like `FALSE`, not
+                // like continuation passthrough.
+                let filter = filter.grouped().cast(PostgresType::Boolean);
+                let filter_is_not_false = Self::Unary(UnaryExpression {
+                    op: UnaryOperator::IsNotFalse,
+                    expr: Box::new(filter.clone()),
+                });
+                let filter_is_not_null = Self::Unary(UnaryExpression {
+                    op: UnaryOperator::Not,
+                    expr: Box::new(Self::Unary(UnaryExpression {
+                        op: UnaryOperator::IsNull,
+                        expr: Box::new(filter),
+                    })),
+                });
+
                 vec![
-                    filter.grouped().cast(PostgresType::Boolean),
+                    Self::all(vec![filter_is_not_false, filter_is_not_null]),
                     null.clone(),
                     null.clone(),
                     null,
@@ -177,11 +193,18 @@
 
     debug_assert_eq!(branch_results.len(), targets.values().len());
 
-    // SwitchInt compares the discriminant against integer values. If the
-    // discriminant is a boolean expression (e.g. `IS NOT NULL`), PostgreSQL
-    // rejects `boolean = integer`. Casting to `::int` is safe for all types
-    // and a no-op when the discriminant is already integral.
-    let discriminant = Box::new(discriminant.grouped().cast(PostgresType::Int));
+    // Preserve the existing boolean-switch behavior (`::int`) for 0/1 cases,
+    // but avoid 32-bit narrowing for wider SwitchInt values.
+    let cast = if targets
+        .values()
+        .iter()
+        .all(|&value| i32::try_from(value).is_ok())
+    {
+        PostgresType::Int
+    } else {
+        PostgresType::Numeric
+    };
+    let discriminant = Box::new(discriminant.grouped().cast(cast.clone()));
 
     let mut discriminant = Some(discriminant);
     let mut conditions = Vec::with_capacity(targets.values().len());
@@ -197,7 +220,7 @@
         let when = Expression::Binary(BinaryExpression {
             op: BinaryOperator::Equal,
             left: discriminant,
-            right: Box::new(Expression::Constant(query::Constant::U128(value))),
+            right: Box::new(Expression::Constant(query::Constant::U128(value)).cast(cast.clone())),
         });
 
         conditions.push((when, then));
@@ -605,6 +628,48 @@
         unreachable!("The postgres island always has an entry block (BasicBlockId::START)")
     }
 
+    fn find_external_entry_target(
+        &self,
+        island: &IslandNode,
+        entry_block: BasicBlockId,
+    ) -> Option<(BasicBlockId, Target<'heap>)> {
+        let mut incoming = None;
+
+        for predecessor in self.body.basic_blocks.predecessors(entry_block) {
+            if island.contains(predecessor) {
+                continue;
+            }
+
+            let terminator = &self.body.basic_blocks[predecessor].terminator.kind;
+
+            if let TerminatorKind::GraphRead(read) = terminator
+                && read.target == entry_block
+            {
+                let target = Target::block(entry_block);
+
+                if let Some((_, existing)) = incoming {
+                    debug_assert_eq!(existing, target);
+                } else {
+                    incoming = Some((predecessor, target));
+                }
+            }
+
+            for &target in terminator.successor_targets() {
+                if target.block != entry_block {
+                    continue;
+                }
+
+                if let Some((_, existing)) = incoming {
+                    debug_assert_eq!(existing, target);
+                } else {
+                    incoming = Some((predecessor, target));
+                }
+            }
+        }
+
+        incoming
+    }
+
     fn compile_island_exit(
         &mut self,
         db: &mut DatabaseContext<'heap, A>,
@@ -631,6 +696,12 @@
         }
 
         for local in live_out {
+            // The environment local is immutable and available independently from
+            // the local expression map, so it does not need to be serialized here.
+            if local == Local::ENV {
+                continue;
+            }
+
             let value = self
                 .locals
                 .lookup(local)
@@ -739,8 +810,17 @@
     {
         debug_assert_eq!(island.target(), TargetId::Postgres);
 
+        let entry_block = self.find_entry_block(island);
+
+        // Non-start islands may receive block arguments from predecessors outside
+        // the island. Seed entry parameters once before starting compilation.
+        if let Some((from, target)) = self.find_external_entry_target(island, entry_block) {
+            let span = self.body.basic_blocks[from].terminator.span;
+            self.assign_params(db, span, &target);
+        }
+
         let mut stack = Vec::new_in(self.scratch.clone());
-        stack.push(Frame::Compile(self.find_entry_block(island)));
+        stack.push(Frame::Compile(entry_block));
 
         let mut results = Vec::new_in(self.scratch.clone());

This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.

Expression::Unary(query::UnaryExpression {
op: query::UnaryOperator::IsNotFalse,
expr: Box::new(field_access(alias, ContinuationColumn::Filter)),
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null filters are treated as matches

High Severity

filter_condition uses IS NOT FALSE, so any NULL filter value passes the row. Continuation::Return writes the compiled predicate directly into filter, and SQL predicates like comparisons on missing JSON paths can evaluate to NULL. That makes unknown predicate results behave like success, changing filter semantics and returning extra rows.

Additional Locations (1)
Fix in Cursor Fix in Web

// discriminant is a boolean expression (e.g. `IS NOT NULL`), PostgreSQL
// rejects `boolean = integer`. Casting to `::int` is safe for all types
// and a no-op when the discriminant is already integral.
let discriminant = Box::new(discriminant.grouped().cast(PostgresType::Int));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switch discriminant forced to 32-bit int

Medium Severity

finish_switch_int always casts the discriminant to PostgresType::Int before comparison. SwitchTargets stores branch values as u128, so non-32-bit cases (large unsigned values or signed values encoded outside 32-bit range) can be miscompared or fail at runtime, selecting the wrong branch.

Fix in Cursor Fix in Web

.locals
.lookup(local)
.unwrap_or_else(|| unreachable!("use before def"))
.clone();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Island exits can panic on captured environment

High Severity

compile_island_exit serializes every live_out local by reading self.locals. TraversalLivenessAnalysis marks Local::ENV as live, but Local::ENV is never stored in self.locals and is handled specially in compile_place_env. When an island exits to interpreter code that still uses captures, this hits unreachable!("use before def").

Fix in Cursor Fix in Web


let mut stack = Vec::new_in(self.scratch.clone());
stack.push(Frame::Compile(self.find_entry_block(island)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-entry Postgres islands miss parameter binding

Medium Severity

compile_body starts each island by pushing Frame::Compile(entry_block) without binding incoming block arguments. For Postgres islands whose entry has predecessors outside the island, entry block params are required but never initialized, so first use can hit use before def or compile incorrect SQL from missing locals.

Additional Locations (1)
Fix in Cursor Fix in Web


// Library Features
allocator_api,
assert_matches,

Check warning

Code scanning / clippy

the feature assert_matches has been stable since 1.95.0 and no longer requires an attribute to enable Warning

the feature assert_matches has been stable since 1.95.0 and no longer requires an attribute to enable
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/apps > hash* Affects HASH (a `hash-*` app) area/apps > hash-graph area/apps area/deps Relates to third-party dependencies (area) area/libs Relates to first-party libraries/crates/packages (area) area/tests New or updated tests type/eng > backend Owned by the @backend team

Development

Successfully merging this pull request may close these issues.

3 participants