diff --git a/.claude/skills/testing-hashql/references/mir-builder-guide.md b/.claude/skills/testing-hashql/references/mir-builder-guide.md index 1967b7e4af7..feb6f086642 100644 --- a/.claude/skills/testing-hashql/references/mir-builder-guide.md +++ b/.claude/skills/testing-hashql/references/mir-builder-guide.md @@ -158,6 +158,7 @@ let body = body!(interner, env; [graph::read::filter]@0/2 -> Bool { | `x = tuple , ;` | Create tuple | `Assign(x, Aggregate(Tuple, [a, b]))` | | `x = struct a: , b: ;` | Create struct | `Assign(x, Aggregate(Struct, [v1, v2]))` | | `x = closure ;` | Create closure | `Assign(x, Aggregate(Closure, [def, env]))` | +| `x = opaque (), ;` | Create opaque wrapper | `Assign(x, Aggregate(Opaque(name), [value]))` | | `x = bin. ;` | Binary operation | `Assign(x, Binary(lhs, op, rhs))` | | `x = un. ;` | Unary operation | `Assign(x, Unary(op, operand))` | | `x = input.load! "name";` | Load required input | `Assign(x, Input(Load { required: true }, "name"))` | @@ -276,6 +277,27 @@ let body = body!(interner, env; [graph::read::filter]@0/2 -> Bool { }); ``` +### Opaque Construction and Projection + +Construct opaque-wrapped values with `opaque (), `. The name must +be wrapped in parentheses because it is a multi-token path. + +```rust +use hashql_core::symbol::sym; + +let body = body!(interner, env; fn@0/0 -> Int { + decl inner: (x: Int, y: Int), wrapped: [Opaque sym::path::Entity; ?], result: Int; + @proj y_field = wrapped.y: Int; + + bb0() { + inner = struct x: 100, y: 200; + wrapped = opaque (sym::path::Entity), inner; + result = load y_field; + return result; + } +}); +``` + ### Direct Function Calls Use a `DefId` variable directly: diff --git a/.direnv/bin/nix-direnv-reload b/.direnv/bin/nix-direnv-reload new file mode 100755 index 00000000000..3436552f073 --- /dev/null +++ b/.direnv/bin/nix-direnv-reload @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +set -e +if [[ ! -d "/Users/bmahmoud/projects/hash/hash" ]]; then + echo "Cannot find source directory; Did you move it?" + echo "(Looking for "/Users/bmahmoud/projects/hash/hash")" + echo 'Cannot force reload with this script - use "direnv reload" manually and then try again' + exit 1 +fi + +# rebuild the cache forcefully +_nix_direnv_force_reload=1 direnv exec "/Users/bmahmoud/projects/hash/hash" true + +# Update the mtime for .envrc. +# This will cause direnv to reload again - but without re-building. +touch "/Users/bmahmoud/projects/hash/hash/.envrc" + +# Also update the timestamp of whatever profile_rc we have. +# This makes sure that we know we are up to date. +touch -r "/Users/bmahmoud/projects/hash/hash/.envrc" "/Users/bmahmoud/projects/hash/hash/.direnv"/*.rc diff --git a/.direnv/flake-inputs/1vl291j7zmnqw700hyj840j9yqv94plk-source b/.direnv/flake-inputs/1vl291j7zmnqw700hyj840j9yqv94plk-source new file mode 120000 index 00000000000..a7ffd5b6a4c --- /dev/null +++ b/.direnv/flake-inputs/1vl291j7zmnqw700hyj840j9yqv94plk-source @@ -0,0 +1 @@ +/nix/store/1vl291j7zmnqw700hyj840j9yqv94plk-source \ No newline at end of file diff --git a/.direnv/flake-inputs/hh6i9rnxx1v6azzva6dc3ivglh9bxnzg-source b/.direnv/flake-inputs/hh6i9rnxx1v6azzva6dc3ivglh9bxnzg-source new file mode 120000 index 00000000000..5b88f2bcb2a --- /dev/null +++ b/.direnv/flake-inputs/hh6i9rnxx1v6azzva6dc3ivglh9bxnzg-source @@ -0,0 +1 @@ +/nix/store/hh6i9rnxx1v6azzva6dc3ivglh9bxnzg-source \ No newline at end of file diff --git a/.direnv/flake-inputs/in6vrz0r1n0f68mhilqxjjn3zzi5qxbi-source b/.direnv/flake-inputs/in6vrz0r1n0f68mhilqxjjn3zzi5qxbi-source new file mode 120000 index 00000000000..d1b4d5ec30c --- /dev/null +++ b/.direnv/flake-inputs/in6vrz0r1n0f68mhilqxjjn3zzi5qxbi-source @@ -0,0 +1 @@ +/nix/store/in6vrz0r1n0f68mhilqxjjn3zzi5qxbi-source \ No newline at end of file diff --git a/.direnv/flake-inputs/q39dfs8rf29qhi1qf5m0hifj1gf586lf-source b/.direnv/flake-inputs/q39dfs8rf29qhi1qf5m0hifj1gf586lf-source new file mode 120000 index 00000000000..5c7e6c0348e --- /dev/null +++ b/.direnv/flake-inputs/q39dfs8rf29qhi1qf5m0hifj1gf586lf-source @@ -0,0 +1 @@ +/nix/store/q39dfs8rf29qhi1qf5m0hifj1gf586lf-source \ No newline at end of file diff --git a/.direnv/flake-inputs/wjfxdzblindbl9sp2hbwhi4iyh5jh348-source b/.direnv/flake-inputs/wjfxdzblindbl9sp2hbwhi4iyh5jh348-source new file mode 120000 index 00000000000..8f5ec629c1b --- /dev/null +++ b/.direnv/flake-inputs/wjfxdzblindbl9sp2hbwhi4iyh5jh348-source @@ -0,0 +1 @@ +/nix/store/wjfxdzblindbl9sp2hbwhi4iyh5jh348-source \ No newline at end of file diff --git a/.direnv/flake-inputs/ylxkr484a1vy6ax7c02vca6f52pd56v4-source b/.direnv/flake-inputs/ylxkr484a1vy6ax7c02vca6f52pd56v4-source new file mode 120000 index 00000000000..535c3c93935 --- /dev/null +++ b/.direnv/flake-inputs/ylxkr484a1vy6ax7c02vca6f52pd56v4-source @@ -0,0 +1 @@ +/nix/store/ylxkr484a1vy6ax7c02vca6f52pd56v4-source \ No newline at end of file diff --git a/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa new file mode 120000 index 00000000000..7a29c056488 --- /dev/null +++ b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa @@ -0,0 +1 @@ +/nix/store/a8x6mxizkswsamqg3rx44c6fnw1g5nl5-hash-env \ No newline at end of file diff --git a/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc new file mode 100644 index 00000000000..9d36c0535fb --- /dev/null +++ b/.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc @@ -0,0 +1,69 @@ +unset shellHook +PATH=${PATH:-} +nix_saved_PATH="$PATH" +XDG_DATA_DIRS=${XDG_DATA_DIRS:-} +nix_saved_XDG_DATA_DIRS="$XDG_DATA_DIRS" +BASH='/sbin/nologin' +HOSTTYPE='aarch64' +IFS=' +' +IN_NIX_SHELL='impure' +export IN_NIX_SHELL +LINENO='80' +MACHTYPE='aarch64-apple-darwin25.3.0' +NIX_BUILD_CORES='18' +export NIX_BUILD_CORES +NIX_STORE='/nix/store' +export NIX_STORE +OLDPWD='' +export OLDPWD +OPTERR='1' +OSTYPE='darwin25.3.0' +PATH='/path-not-set' +export PATH +PS4='+ ' +SHELL='/sbin/nologin' +builder='/nix/store/in4yc03diyvs2n2wgf3nva4hbvml8v1j-bash-interactive-5.3p9/bin/bash' +export builder +dontAddDisableDepTrack='1' +export dontAddDisableDepTrack +name='hash-env' +export name +out='/Users/bmahmoud/projects/hash/hash/outputs/out' +export out +outputs='out' +shellHook='# Remove all the unnecessary noise that is set by the build env +unset NIX_BUILD_TOP NIX_BUILD_CORES NIX_STORE +unset TEMP TEMPDIR TMP TMPDIR +# $name variable is preserved to keep it compatible with pure shell https://github.com/sindresorhus/pure/blob/47c0c881f0e7cfdb5eaccd335f52ad17b897c060/pure.zsh#L235 +unset builder out shellHook stdenv system +# Flakes stuff +unset dontAddDisableDepTrack outputs + +# For `nix develop`. We get /noshell on Linux and /sbin/nologin on macOS. +if [[ "$SHELL" == "/noshell" || "$SHELL" == "/sbin/nologin" ]]; then + export SHELL=/nix/store/in4yc03diyvs2n2wgf3nva4hbvml8v1j-bash-interactive-5.3p9/bin/bash +fi + +# Load the environment +source "/nix/store/z6v9sv2fk37bc7adkmfdgjjjfzhyyflz-hash-dir/env.bash" +' +export shellHook +stdenv='/nix/store/c0pzk2bfla7hyzwq4vq6rk92m5dpwa4j-naked-stdenv' +export stdenv +system='aarch64-darwin' +export system +runHook () +{ + + eval "$shellHook"; + unset runHook +} +PATH="$PATH${nix_saved_PATH:+:$nix_saved_PATH}" +XDG_DATA_DIRS="$XDG_DATA_DIRS${nix_saved_XDG_DATA_DIRS:+:$nix_saved_XDG_DATA_DIRS}" +export NIX_BUILD_TOP="$(mktemp -d -t nix-shell.XXXXXX)" +export TMP="$NIX_BUILD_TOP" +export TMPDIR="$NIX_BUILD_TOP" +export TEMP="$NIX_BUILD_TOP" +export TEMPDIR="$NIX_BUILD_TOP" +eval "${shellHook:-}" diff --git a/Cargo.lock b/Cargo.lock index b4eaeed8de1..b888bed3cb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -334,6 +334,22 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "astral-tokio-tar" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce73b17c62717c4b6a9af10b43e87c578b0cac27e00666d48304d3b7d2c0693" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + [[package]] name = "async-event" version = "0.2.1" @@ -373,6 +389,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -1086,6 +1124,80 @@ dependencies = [ "objc2", ] +[[package]] +name = "bollard" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" +dependencies = [ + "async-stream", + "base64", + "bitflags 2.11.1", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http 1.4.0", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.4", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic 0.14.5", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost 0.14.3", + "prost-types", + "tonic 0.14.5", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "base64", + "bollard-buildkit-proto", + "bytes", + "prost 0.14.3", + "serde", + "serde_json", + "serde_repr", + "time", +] + [[package]] name = "bon" version = "3.9.1" @@ -2281,6 +2393,17 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" +[[package]] +name = "docker_credential" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4564c274ebf369f501de192b02a0b81a5c4bda375abfe526aa70fc702fa6fa0" +dependencies = [ + "base64", + "serde", + "serde_json", +] + [[package]] name = "document-features" version = "0.2.12" @@ -2517,7 +2640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2559,6 +2682,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5692dd7b5a1978a5aeb0ce83b7655c58ca8efdcb79d21036ea249da95afec2c6" +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "euclid" version = "0.22.14" @@ -2610,6 +2743,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "ferroid" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" +dependencies = [ + "portable-atomic", + "rand 0.10.1", + "web-time", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -2627,6 +2771,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -2805,7 +2960,10 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" dependencies = [ + "fastrand", "futures-core", + "futures-io", + "parking", "pin-project-lite", ] @@ -3939,17 +4097,36 @@ dependencies = [ name = "hashql-eval" version = "0.0.0" dependencies = [ + "bytes", "derive_more", + "error-stack", + "futures-lite", + "hash-graph-authorization", "hash-graph-postgres-store", "hash-graph-store", + "hash-graph-test-data", "hashql-compiletest", "hashql-core", "hashql-diagnostics", "hashql-hir", "hashql-mir", "insta", + "libtest-mimic", + "postgres-protocol", + "postgres-types", + "regex", + "serde", + "serde_json", + "similar-asserts", "simple-mermaid", + "testcontainers", + "testcontainers-modules", + "tokio", + "tokio-postgres", + "tokio-util", "type-system", + "url", + "uuid", ] [[package]] @@ -4113,6 +4290,15 @@ dependencies = [ "digest 0.11.2", ] +[[package]] +name = "home" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "hostname" version = "0.4.2" @@ -4246,6 +4432,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + [[package]] name = "hyper-rustls" version = "0.27.9" @@ -4299,6 +4500,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -4647,7 +4863,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -5300,7 +5516,10 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ + "bitflags 2.11.1", "libc", + "plain", + "redox_syscall 0.7.5", ] [[package]] @@ -5907,7 +6126,21 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", ] [[package]] @@ -5920,6 +6153,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -5956,12 +6198,34 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-modular" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -6624,11 +6888,36 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec 1.15.1", "windows-link", ] +[[package]] +name = "parse-display" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06af5f9333eb47bd9ba8462d612e37a8328a5cb80b13f0af4de4c3b89f52dee5" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc9252f259500ee570c75adcc4e317fa6f57a1e47747d622e0bf838002a7b790" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn 2.0.117", +] + [[package]] name = "paste" version = "1.0.15" @@ -6867,6 +7156,12 @@ dependencies = [ "spki", ] +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "plist" version = "1.9.0" @@ -7410,7 +7705,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -7674,6 +7969,15 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -8045,7 +8349,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -8104,7 +8408,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -8470,6 +8774,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -9104,7 +9419,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -9170,7 +9485,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -9189,7 +9504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -9299,6 +9614,46 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "testcontainers" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "ferroid", + "futures", + "http 1.4.0", + "itertools 0.14.0", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5985fde5befe4ffa77a052e035e16c2da86e8bae301baa9f9904ad3c494d357" +dependencies = [ + "testcontainers", +] + [[package]] name = "text-size" version = "1.1.1" @@ -10681,7 +11036,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -10820,6 +11175,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -11255,6 +11619,16 @@ dependencies = [ "time", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xml-rs" version = "0.8.28" diff --git a/Cargo.toml b/Cargo.toml index 599b4741ee0..aaabe703f5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -259,6 +259,8 @@ temporalio-client = { git = "https://github.com/temporalio/sdk- temporalio-common = { git = "https://github.com/temporalio/sdk-core", rev = "231e21c" } test-log = { version = "0.2.18", default-features = false } test-strategy = { version = "0.4.3", default-features = false } +testcontainers = { version = "0.27.1", default-features = false } +testcontainers-modules = { version = "0.15.0", default-features = false } text-size = { version = "1.1.1", default-features = false } thiserror = { version = "2.0.17", default-features = false } time = { version = "0.3.44", default-features = false } diff --git a/apps/hash-graph/docs/dependency-diagram.mmd b/apps/hash-graph/docs/dependency-diagram.mmd index f9c26a72305..8814115ea5f 100644 --- a/apps/hash-graph/docs/dependency-diagram.mmd +++ b/apps/hash-graph/docs/dependency-diagram.mmd @@ -91,7 +91,6 @@ graph TD 23 -.-> 24 24 --> 27 24 --> 31 - 24 --> 39 25 --> 2 25 --> 26 25 --> 29 diff --git a/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd b/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd index cee946bdefb..b9a27b2f1e8 100644 --- a/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd +++ b/libs/@blockprotocol/type-system/rust/docs/dependency-diagram.mmd @@ -61,7 +61,6 @@ graph TD 15 -.-> 16 16 --> 17 16 --> 20 - 16 --> 22 17 --> 6 17 --> 19 18 -.-> 16 diff --git a/libs/@local/codec/docs/dependency-diagram.mmd b/libs/@local/codec/docs/dependency-diagram.mmd index b2c62ea4a08..e40e05fce7a 100644 --- a/libs/@local/codec/docs/dependency-diagram.mmd +++ b/libs/@local/codec/docs/dependency-diagram.mmd @@ -71,7 +71,6 @@ graph TD 19 -.-> 20 20 --> 22 20 --> 25 - 20 --> 27 21 --> 2 22 --> 6 22 --> 24 diff --git a/libs/@local/graph/api/docs/dependency-diagram.mmd b/libs/@local/graph/api/docs/dependency-diagram.mmd index 4eee95b4a8b..57d0ebd98d4 100644 --- a/libs/@local/graph/api/docs/dependency-diagram.mmd +++ b/libs/@local/graph/api/docs/dependency-diagram.mmd @@ -92,7 +92,6 @@ graph TD 23 -.-> 24 24 --> 27 24 --> 31 - 24 --> 39 25 --> 2 25 --> 26 25 --> 29 diff --git a/libs/@local/graph/authorization/docs/dependency-diagram.mmd b/libs/@local/graph/authorization/docs/dependency-diagram.mmd index a57506ba2cc..ce8b50f65b7 100644 --- a/libs/@local/graph/authorization/docs/dependency-diagram.mmd +++ b/libs/@local/graph/authorization/docs/dependency-diagram.mmd @@ -61,7 +61,6 @@ graph TD 15 -.-> 16 16 --> 17 16 --> 20 - 16 --> 22 17 --> 6 17 --> 19 18 -.-> 16 diff --git a/libs/@local/graph/migrations/docs/dependency-diagram.mmd b/libs/@local/graph/migrations/docs/dependency-diagram.mmd index e4c8194c510..9e314eca731 100644 --- a/libs/@local/graph/migrations/docs/dependency-diagram.mmd +++ b/libs/@local/graph/migrations/docs/dependency-diagram.mmd @@ -33,7 +33,6 @@ graph TD 5 -.-> 6 6 --> 7 6 --> 10 - 6 --> 12 7 --> 4 7 --> 9 8 -.-> 6 diff --git a/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd b/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd index da7204a41bb..84ed6a1efa0 100644 --- a/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd +++ b/libs/@local/graph/postgres-store/docs/dependency-diagram.mmd @@ -62,7 +62,6 @@ graph TD 15 -.-> 16 16 --> 17 16 --> 20 - 16 --> 24 17 --> 8 17 --> 19 18 -.-> 16 diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs index 9ac8e9bfc39..84a9379349a 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs @@ -22,6 +22,7 @@ pub enum Function { JsonExtractAsText(Box, PathToken<'static>), JsonExtractPath(Vec), JsonContains(Box, Box), + JsonScalar(Box), JsonBuildArray(Vec), JsonBuildObject(Vec<(Expression, Expression)>), JsonPathQueryFirst(Box, Box), @@ -32,8 +33,26 @@ pub enum Function { elements: Vec, element_type: PostgresType, }, + /// Converts any SQL value to jsonb. + /// + /// Transpiles to `to_jsonb()` in PostgreSQL. Passes through jsonb + /// values unchanged; wraps text, uuid, integer, boolean, etc. as jsonb + /// scalars. + ToJson(Box), + /// Returns the first non-NULL argument. + /// + /// Transpiles to `COALESCE(expr, fallback)`. + Coalesce(Box, Box), Lower(Box), Upper(Box), + LowerInc(Box), + UpperInc(Box), + LowerInf(Box), + UpperInf(Box), + /// Extracts the epoch as milliseconds since Unix epoch from a timestamp expression. + /// + /// Transpiles to `(extract(epoch from ) * 1000)::int8` in PostgreSQL. + ExtractEpochMs(Box), Unnest(Vec), Now, } @@ -60,6 +79,11 @@ impl Transpile for Function { expression.transpile(fmt)?; fmt.write_char(')') } + Self::JsonScalar(expression) => { + fmt.write_str("json_scalar(")?; + expression.transpile(fmt)?; + fmt.write_char(')') + } Self::JsonExtractPath(paths) => { fmt.write_str("jsonb_extract_path(")?; for (i, expression) in paths.iter().enumerate() { @@ -112,6 +136,18 @@ impl Transpile for Function { fmt.write_char(')') } Self::Now => fmt.write_str("now()"), + Self::ToJson(expression) => { + fmt.write_str("to_jsonb(")?; + expression.transpile(fmt)?; + fmt.write_char(')') + } + Self::Coalesce(expression, fallback) => { + fmt.write_str("COALESCE(")?; + expression.transpile(fmt)?; + fmt.write_str(", ")?; + fallback.transpile(fmt)?; + fmt.write_char(')') + } Self::Lower(expression) => { fmt.write_str("lower(")?; expression.transpile(fmt)?; @@ -122,6 +158,31 @@ impl Transpile for Function { expression.transpile(fmt)?; fmt.write_char(')') } + Self::LowerInc(expression) => { + fmt.write_str("lower_inc(")?; + expression.transpile(fmt)?; + fmt.write_char(')') + } + Self::UpperInc(expression) => { + fmt.write_str("upper_inc(")?; + expression.transpile(fmt)?; + fmt.write_char(')') + } + Self::LowerInf(expression) => { + fmt.write_str("lower_inf(")?; + expression.transpile(fmt)?; + fmt.write_char(')') + } + Self::UpperInf(expression) => { + fmt.write_str("upper_inf(")?; + expression.transpile(fmt)?; + fmt.write_char(')') + } + Self::ExtractEpochMs(expression) => { + fmt.write_str("(extract(epoch from ")?; + expression.transpile(fmt)?; + fmt.write_str(") * 1000)::int8") + } Self::Unnest(expression) => { fmt.write_str("UNNEST(")?; @@ -209,6 +270,7 @@ pub enum PostgresType { Int, BigInt, Boolean, + TimestampTzRange, } impl Transpile for PostgresType { @@ -227,6 +289,7 @@ impl Transpile for PostgresType { Self::Int => fmt.write_str("int"), Self::BigInt => fmt.write_str("bigint"), Self::Boolean => fmt.write_str("boolean"), + Self::TimestampTzRange => fmt.write_str("tstzrange"), } } } @@ -551,6 +614,11 @@ impl Expression { Self::Grouped(Box::new(self)) } + #[must_use] + pub fn coalesce(self, fallback: Self) -> Self { + Self::Function(Function::Coalesce(Box::new(self), Box::new(fallback))) + } + #[must_use] pub fn starts_with(lhs: Self, rhs: Self) -> Self { Self::StartsWith(Box::new(lhs), Box::new(rhs)) @@ -570,6 +638,11 @@ impl Expression { pub fn cast(self, r#type: PostgresType) -> Self { Self::Cast(Box::new(self), r#type) } + + #[must_use] + pub fn json_scalar(self) -> Self { + Self::Function(Function::JsonScalar(Box::new(self))) + } } impl Transpile for Expression { diff --git a/libs/@local/graph/store/docs/dependency-diagram.mmd b/libs/@local/graph/store/docs/dependency-diagram.mmd index 9befe6246fe..d0627e3dd8b 100644 --- a/libs/@local/graph/store/docs/dependency-diagram.mmd +++ b/libs/@local/graph/store/docs/dependency-diagram.mmd @@ -61,7 +61,6 @@ graph TD 15 -.-> 16 16 --> 17 16 --> 20 - 16 --> 22 17 --> 6 17 --> 19 18 -.-> 16 diff --git a/libs/@local/graph/temporal-versioning/docs/dependency-diagram.mmd b/libs/@local/graph/temporal-versioning/docs/dependency-diagram.mmd index 4a62d2f471c..146d1f192df 100644 --- a/libs/@local/graph/temporal-versioning/docs/dependency-diagram.mmd +++ b/libs/@local/graph/temporal-versioning/docs/dependency-diagram.mmd @@ -61,7 +61,6 @@ graph TD 15 -.-> 16 16 --> 17 16 --> 20 - 16 --> 22 17 --> 6 17 --> 19 18 -.-> 16 diff --git a/libs/@local/graph/types/docs/dependency-diagram.mmd b/libs/@local/graph/types/docs/dependency-diagram.mmd index 1ad837b1d54..5ddabb57b44 100644 --- a/libs/@local/graph/types/docs/dependency-diagram.mmd +++ b/libs/@local/graph/types/docs/dependency-diagram.mmd @@ -61,7 +61,6 @@ graph TD 15 -.-> 16 16 --> 17 16 --> 20 - 16 --> 22 17 --> 6 17 --> 19 18 -.-> 16 diff --git a/libs/@local/graph/validation/docs/dependency-diagram.mmd b/libs/@local/graph/validation/docs/dependency-diagram.mmd index a7ea48b3400..a84b3c63ba9 100644 --- a/libs/@local/graph/validation/docs/dependency-diagram.mmd +++ b/libs/@local/graph/validation/docs/dependency-diagram.mmd @@ -54,7 +54,6 @@ graph TD 13 -.-> 14 14 --> 15 14 --> 18 - 14 --> 20 15 --> 6 15 --> 17 16 -.-> 14 diff --git a/libs/@local/harpc/wire-protocol/docs/dependency-diagram.mmd b/libs/@local/harpc/wire-protocol/docs/dependency-diagram.mmd index eb86da026a0..4bb5a7ed3f0 100644 --- a/libs/@local/harpc/wire-protocol/docs/dependency-diagram.mmd +++ b/libs/@local/harpc/wire-protocol/docs/dependency-diagram.mmd @@ -69,7 +69,6 @@ graph TD 18 -.-> 19 19 --> 21 19 --> 24 - 19 --> 26 20 --> 2 21 --> 5 21 --> 23 diff --git a/libs/@local/hashql/ast/docs/dependency-diagram.mmd b/libs/@local/hashql/ast/docs/dependency-diagram.mmd index 8ea54320abf..1c4dfe4d89f 100644 --- a/libs/@local/hashql/ast/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/ast/docs/dependency-diagram.mmd @@ -68,7 +68,6 @@ graph TD 15 -.-> 16 16 --> 19 16 --> 23 - 16 --> 31 17 --> 2 17 --> 18 17 --> 21 diff --git a/libs/@local/hashql/compiletest/Cargo.toml b/libs/@local/hashql/compiletest/Cargo.toml index d44b37a3d41..65b99360e5e 100644 --- a/libs/@local/hashql/compiletest/Cargo.toml +++ b/libs/@local/hashql/compiletest/Cargo.toml @@ -8,18 +8,18 @@ version.workspace = true [dependencies] # Public workspace dependencies +hashql-ast = { workspace = true, public = true } +hashql-core = { workspace = true, public = true } +hashql-diagnostics = { workspace = true, features = ["render"], public = true } +hashql-eval = { workspace = true, features = ["graph"], public = true } +hashql-hir = { workspace = true, public = true } +hashql-mir = { workspace = true, public = true } +hashql-syntax-jexpr = { workspace = true, public = true } # Public third-party dependencies # Private workspace dependencies -error-stack = { workspace = true } -hashql-ast = { workspace = true } -hashql-core = { workspace = true } -hashql-diagnostics = { workspace = true, features = ["render"] } -hashql-eval = { workspace = true, features = ["graph"] } -hashql-hir = { workspace = true } -hashql-mir = { workspace = true } -hashql-syntax-jexpr = { workspace = true } +error-stack = { workspace = true } # Private third-party dependencies ansi-to-tui = { workspace = true } diff --git a/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd b/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd index f589b3979ef..4d2d74f3e20 100644 --- a/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/compiletest/docs/dependency-diagram.mmd @@ -68,7 +68,6 @@ graph TD 15 -.-> 16 16 --> 19 16 --> 23 - 16 --> 31 17 --> 2 17 --> 18 17 --> 21 diff --git a/libs/@local/hashql/compiletest/src/harness/test/discover.rs b/libs/@local/hashql/compiletest/src/harness/test/discover.rs index 6b3148bcb32..cb53e2bfa2a 100644 --- a/libs/@local/hashql/compiletest/src/harness/test/discover.rs +++ b/libs/@local/hashql/compiletest/src/harness/test/discover.rs @@ -175,6 +175,10 @@ fn find_test_cases(entry_point: &EntryPoint) -> Vec { ) }; + if spec.skip == Some(true) { + continue; + } + cases.push(TestCase { spec: spec.clone(), path: candidate, diff --git a/libs/@local/hashql/compiletest/src/harness/test/mod.rs b/libs/@local/hashql/compiletest/src/harness/test/mod.rs index 160159e3961..1d9264d1416 100644 --- a/libs/@local/hashql/compiletest/src/harness/test/mod.rs +++ b/libs/@local/hashql/compiletest/src/harness/test/mod.rs @@ -7,6 +7,7 @@ use guppy::graph::{PackageGraph, PackageMetadata}; #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] pub(crate) struct Spec { pub suite: String, + pub skip: Option, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/libs/@local/hashql/compiletest/src/lib.rs b/libs/@local/hashql/compiletest/src/lib.rs index e35cb25d845..c2280893dcd 100644 --- a/libs/@local/hashql/compiletest/src/lib.rs +++ b/libs/@local/hashql/compiletest/src/lib.rs @@ -15,6 +15,7 @@ string_from_utf8_lossy_owned, try_trait_v2, vec_from_fn, + macro_metavar_expr )] extern crate alloc; @@ -31,6 +32,7 @@ use self::{ mod annotation; mod harness; +pub mod pipeline; pub mod runner; mod suite; mod ui; diff --git a/libs/@local/hashql/compiletest/src/pipeline.rs b/libs/@local/hashql/compiletest/src/pipeline.rs new file mode 100644 index 00000000000..a0f7d427d39 --- /dev/null +++ b/libs/@local/hashql/compiletest/src/pipeline.rs @@ -0,0 +1,277 @@ +//! Staged compilation pipeline from J-Expr source to prepared SQL queries. +//! +//! [`Pipeline`] drives the full HashQL compilation sequence: parsing J-Expr +//! source into an AST, lowering through HIR and MIR, running optimization and +//! execution analysis passes, and finally compiling to +//! [`PreparedQueries`](hashql_eval::postgres::PreparedQueries) ready for PostgreSQL execution. +//! +//! Each stage is exposed as a separate method so callers can inspect or test +//! intermediate results. Diagnostics (warnings, advisories) accumulate in +//! [`Pipeline::diagnostics`] across all stages; fatal errors are returned +//! immediately as [`BoxedDiagnostic`]. +//! +//! Intended for use by the compiletest harness and integration test binaries +//! that need the full compilation pipeline without assembling it from +//! individual crate APIs. + +use hashql_core::{ + heap::{Heap, ResetAllocator as _, Scratch}, + module::ModuleRegistry, + span::{SpanId, SpanTable}, + r#type::environment::Environment, +}; +use hashql_diagnostics::{ + Diagnostic, DiagnosticCategory, Failure, Status, Success, diagnostic::BoxedDiagnostic, + issues::BoxedDiagnosticIssues, source::SourceId, +}; +use hashql_hir::context::HirContext; +use hashql_mir::{ + body::Body, + context::MirContext, + def::{DefId, DefIdSlice, DefIdVec}, + pass::{ + Changed, GlobalAnalysisPass as _, GlobalTransformPass as _, GlobalTransformState, + analysis::SizeEstimationAnalysis, + execution::{ExecutionAnalysis, ExecutionAnalysisResidual}, + transform::{Inline, InlineConfig, PostInline, PreInline}, + }, + reify::ReifyContext, +}; +use hashql_syntax_jexpr::span::Span; + +/// Unwraps a [`Status`] into its success value, draining advisories and +/// secondary diagnostics into the shared accumulator. +/// +/// On failure, secondary diagnostics are drained and the primary diagnostic +/// is returned as the error. +fn process_status( + diagnostics: &mut BoxedDiagnosticIssues<'static, SpanId>, + status: Status, +) -> Result> +where + C: DiagnosticCategory + 'static, +{ + match status { + Ok(Success { value, advisories }) => { + diagnostics.extend( + advisories + .into_iter() + .map(|advisory| advisory.generalize().boxed()), + ); + + Ok(value) + } + Err(Failure { primary, secondary }) => { + diagnostics.extend(secondary.into_iter().map(Diagnostic::boxed)); + + Err(primary.generalize().boxed()) + } + } +} + +macro_rules! bind_tri { + ($diagnostics:expr) => { + macro_rules! tri { + ($$status:expr) => { + process_status($diagnostics, $$status)? + }; + } + }; +} + +/// Staged compilation driver from J-Expr source to prepared SQL queries. +/// +/// Owns the shared compilation state (heap reference, type environment, span +/// table, scratch allocator) and accumulates non-fatal diagnostics across +/// stages. Call the methods in order: +/// +/// 1. [`parse`](Self::parse): J-Expr bytes to AST +/// 2. [`lower`](Self::lower): AST through HIR to MIR bodies +/// 3. [`transform`](Self::transform): MIR optimization passes (inlining) +/// 4. [`prepare`](Self::prepare): execution analysis +/// +/// After each stage, check [`diagnostics`](Self::diagnostics) for warnings. +/// Fatal errors short-circuit via the `Result` return. +pub struct Pipeline<'heap> { + pub heap: &'heap Heap, + pub scratch: Scratch, + pub env: Environment<'heap>, + pub spans: SpanTable, + pub diagnostics: BoxedDiagnosticIssues<'static, SpanId>, +} + +impl<'heap> Pipeline<'heap> { + /// Creates a new pipeline bound to `heap`. + /// + /// Initializes the type environment, span table, scratch allocator, and + /// an empty diagnostic accumulator. + pub fn new(heap: &'heap Heap) -> Self { + Self { + heap, + env: Environment::new(heap), + spans: SpanTable::new(SourceId::new_unchecked(0x00)), + diagnostics: BoxedDiagnosticIssues::default(), + scratch: Scratch::new(), + } + } + + /// Parses J-Expr source bytes into an AST expression. + /// + /// # Errors + /// + /// Returns a diagnostic if the input is not valid J-Expr syntax. + pub fn parse( + &mut self, + content: impl AsRef<[u8]>, + ) -> Result, BoxedDiagnostic<'static, SpanId>> { + let mut parser = hashql_syntax_jexpr::Parser::new(self.heap, &mut self.spans); + + parser + .parse_expr(content.as_ref()) + .map_err(Diagnostic::boxed) + } + + /// Lowers an AST expression through HIR into MIR. + /// + /// Performs AST type lowering, HIR node construction, HIR specialization + /// and lowering, then reifies the result into MIR bodies. Returns the + /// MIR interner, the entry definition, and the complete set of bodies. + /// + /// # Errors + /// + /// Returns a diagnostic if any lowering stage fails (type resolution, + /// HIR construction, specialization, or MIR reification). + pub fn lower( + &mut self, + mut expr: hashql_ast::node::expr::Expr<'heap>, + ) -> Result< + ( + hashql_mir::intern::Interner<'heap>, + DefId, + DefIdVec>, + ), + BoxedDiagnostic<'static, SpanId>, + > { + bind_tri!(&mut self.diagnostics); + let registry = ModuleRegistry::new(&self.env); + + let types = tri!(hashql_ast::lowering::lower( + self.heap.intern_symbol("::main"), + &mut expr, + &self.env, + ®istry, + )); + + let hir_interner = hashql_hir::intern::Interner::new(self.heap); + let mut hir_context = HirContext::new(&hir_interner, ®istry); + + let node = tri!(hashql_hir::node::NodeData::from_ast( + expr, + &mut hir_context, + &types + )); + + let node = tri!(hashql_hir::lower::lower( + node, + &types, + &mut self.env, + &mut hir_context, + )); + + let mut bodies = DefIdVec::new(); + + let mir_interner = hashql_mir::intern::Interner::new(self.heap); + let mut mir_context = MirContext::new(&self.env, &mir_interner); + let mut reify_context = ReifyContext { + bodies: &mut bodies, + mir: &mut mir_context, + hir: &hir_context, + }; + + let entry = tri!(hashql_mir::reify::from_hir(node, &mut reify_context)); + + // drain the context, because we're going to re-create it + self.diagnostics.extend( + mir_context + .diagnostics + .into_iter() + .map(hashql_diagnostics::Diagnostic::boxed), + ); + + Ok((mir_interner, entry, bodies)) + } + + /// Runs MIR optimization passes on the body set. + /// + /// Applies pre-inline cleanup, function inlining, and post-inline + /// simplification in sequence. Bodies are modified in place. + /// + /// # Errors + /// + /// Returns a diagnostic if any transform pass emits a fatal error. + pub fn transform( + &mut self, + interner: &hashql_mir::intern::Interner<'heap>, + bodies: &mut DefIdSlice>, + ) -> Result<(), BoxedDiagnostic<'static, SpanId>> { + let mut context = MirContext::new(&self.env, interner); + let mut state = GlobalTransformState::new_in(&*bodies, self.heap); + + self.scratch.reset(); + + let mut pass = PreInline::new_in(&mut self.scratch); + let _: Changed = pass.run(&mut context, &mut state, bodies); + self.scratch.reset(); + + let mut pass = Inline::new_in(InlineConfig::default(), &mut self.scratch); + let _: Changed = pass.run(&mut context, &mut state, bodies); + self.scratch.reset(); + + let mut pass = PostInline::new_in(&mut self.scratch); + let _: Changed = pass.run(&mut context, &mut state, bodies); + self.scratch.reset(); + + let status = context.diagnostics.generalize().boxed().into_status(()); + process_status(&mut self.diagnostics, status)?; + + Ok(()) + } + + /// Runs execution analysis and compiles MIR bodies to prepared SQL queries. + /// + /// Performs size estimation, execution island analysis (determining which + /// parts of each body run on PostgreSQL vs the interpreter), then compiles + /// the PostgreSQL islands into [`PreparedQueries`](hashql_eval::postgres::PreparedQueries) + /// containing the SQL statements, parameter bindings, and column descriptors. + /// + /// # Errors + /// + /// Returns a diagnostic if execution analysis or SQL compilation fails. + pub fn prepare<'bodies>( + &mut self, + interner: &hashql_mir::intern::Interner<'heap>, + bodies: &'bodies mut DefIdSlice>, + ) -> Result< + DefIdVec>, &'heap Heap>, + BoxedDiagnostic<'static, SpanId>, + > { + let mut context = MirContext::new(&self.env, interner); + + let mut pass = SizeEstimationAnalysis::new_in(&self.scratch); + pass.run(&mut context, bodies); + let footprints = pass.finish(); + self.scratch.reset(); + + let pass = ExecutionAnalysis { + footprints: &footprints, + scratch: &mut self.scratch, + }; + let analysis = pass.run_all_in(&mut context, bodies, self.heap); + self.scratch.reset(); + + let status = context.diagnostics.generalize().boxed().into_status(()); + process_status(&mut self.diagnostics, status)?; + + Ok(analysis) + } +} diff --git a/libs/@local/hashql/compiletest/src/suite/eval_postgres.rs b/libs/@local/hashql/compiletest/src/suite/eval_postgres.rs index 496d237f8a0..721796be1e9 100644 --- a/libs/@local/hashql/compiletest/src/suite/eval_postgres.rs +++ b/libs/@local/hashql/compiletest/src/suite/eval_postgres.rs @@ -117,8 +117,14 @@ impl Suite for EvalPostgres { let mir_buf = format_mir_with_placement(heap, &environment, &bodies, &analysis); secondary_outputs.insert("mir", mir_buf); - let mut context = - EvalContext::new_in(&environment, &bodies, &analysis, context.heap, &mut scratch); + let mut context = EvalContext::new_in( + &environment, + &interner, + &bodies, + &analysis, + context.heap, + &mut scratch, + ); scratch.reset(); // Inside of **all** the bodies, find the `GraphRead` terminators to compile. @@ -128,7 +134,7 @@ impl Suite for EvalPostgres { for body in &bodies { for block in &*body.basic_blocks { if let TerminatorKind::GraphRead(read) = &block.terminator.kind { - let prepared_query = compiler.compile(read); + let prepared_query = compiler.compile_graph_read(read); prepared_queries.push(prepared_query); } } diff --git a/libs/@local/hashql/core/docs/dependency-diagram.mmd b/libs/@local/hashql/core/docs/dependency-diagram.mmd index c66e8d75920..af15c76c147 100644 --- a/libs/@local/hashql/core/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/core/docs/dependency-diagram.mmd @@ -41,7 +41,6 @@ graph TD 6 -.-> 7 7 --> 10 7 --> 14 - 7 --> 19 8 --> 1 8 --> 9 8 --> 12 diff --git a/libs/@local/hashql/eval/Cargo.toml b/libs/@local/hashql/eval/Cargo.toml index bea6be986f8..96ae18e8dda 100644 --- a/libs/@local/hashql/eval/Cargo.toml +++ b/libs/@local/hashql/eval/Cargo.toml @@ -21,12 +21,33 @@ hashql-core = { workspace = true } type-system = { workspace = true, optional = true } # Private third-party dependencies -derive_more = { workspace = true, features = ["display"] } -simple-mermaid = { workspace = true } +bytes.workspace = true +derive_more = { workspace = true, features = ["display"] } +futures-lite = "2.6.1" +postgres-protocol.workspace = true +postgres-types = { workspace = true, features = ["uuid-1"] } +serde = { workspace = true } +serde_json = { workspace = true, features = ["raw_value"] } +simple-mermaid = { workspace = true } +tokio.workspace = true +tokio-postgres.workspace = true +tokio-util = { workspace = true, features = ["rt"] } +url.workspace = true +uuid.workspace = true [dev-dependencies] -hashql-compiletest = { workspace = true } -insta = { workspace = true } +error-stack.workspace = true +hash-graph-authorization = { workspace = true } +hash-graph-store.workspace = true +hash-graph-test-data.workspace = true +hashql-compiletest = { workspace = true } +hashql-diagnostics = { workspace = true, features = ["render"] } +insta = { workspace = true } +libtest-mimic = { workspace = true } +regex = { workspace = true } +similar-asserts = { workspace = true } +testcontainers = { workspace = true, features = ["reusable-containers"] } +testcontainers-modules = { workspace = true, features = ["postgres"] } [features] graph = ["dep:hash-graph-store", "dep:type-system"] @@ -38,6 +59,10 @@ workspace = true name = "compiletest" harness = false +[[test]] +name = "orchestrator" +harness = false + [package.metadata.sync.turborepo] ignore-dev-dependencies = [ "hashql-compiletest", diff --git a/libs/@local/hashql/eval/docs/dependency-diagram.mmd b/libs/@local/hashql/eval/docs/dependency-diagram.mmd index cfc298ac746..09fbd7269eb 100644 --- a/libs/@local/hashql/eval/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/eval/docs/dependency-diagram.mmd @@ -68,7 +68,6 @@ graph TD 15 -.-> 16 16 --> 19 16 --> 23 - 16 --> 31 17 --> 2 17 --> 18 17 --> 21 diff --git a/libs/@local/hashql/eval/package.json b/libs/@local/hashql/eval/package.json index d08b21e799e..e6f969e8c66 100644 --- a/libs/@local/hashql/eval/package.json +++ b/libs/@local/hashql/eval/package.json @@ -17,5 +17,10 @@ "@rust/hashql-diagnostics": "workspace:*", "@rust/hashql-hir": "workspace:*", "@rust/hashql-mir": "workspace:*" + }, + "devDependencies": { + "@rust/error-stack": "workspace:*", + "@rust/hash-graph-authorization": "workspace:*", + "@rust/hash-graph-test-data": "workspace:*" } } diff --git a/libs/@local/hashql/eval/src/context.rs b/libs/@local/hashql/eval/src/context.rs index 6098a7d448d..dd7c7bd15d4 100644 --- a/libs/@local/hashql/eval/src/context.rs +++ b/libs/@local/hashql/eval/src/context.rs @@ -11,6 +11,7 @@ use hashql_mir::{ local::Local, }, def::{DefId, DefIdSlice, DefIdVec}, + intern::Interner, pass::{ analysis::dataflow::{ TraversalLivenessAnalysis, @@ -50,6 +51,7 @@ impl Index<(DefId, BasicBlockId)> for LiveOut { pub struct EvalContext<'ctx, 'heap, A: Allocator> { pub env: &'ctx Environment<'heap>, + pub interner: &'ctx Interner<'heap>, pub bodies: &'ctx DefIdSlice>, pub execution: &'ctx DefIdSlice>>, @@ -62,6 +64,7 @@ pub struct EvalContext<'ctx, 'heap, A: Allocator> { impl<'ctx, 'heap, A: Allocator> EvalContext<'ctx, 'heap, A> { pub fn new_in( env: &'ctx Environment<'heap>, + interner: &'ctx Interner<'heap>, bodies: &'ctx DefIdSlice>, execution: &'ctx DefIdSlice>>, alloc: A, @@ -109,6 +112,7 @@ impl<'ctx, 'heap, A: Allocator> EvalContext<'ctx, 'heap, A> { Self { env, + interner, bodies, execution, live_out: LiveOut(live_out), diff --git a/libs/@local/hashql/eval/src/lib.rs b/libs/@local/hashql/eval/src/lib.rs index 418c7ec9db3..4a2d869a75b 100644 --- a/libs/@local/hashql/eval/src/lib.rs +++ b/libs/@local/hashql/eval/src/lib.rs @@ -14,6 +14,8 @@ iter_array_chunks, iterator_try_collect, maybe_uninit_fill, + impl_trait_in_assoc_type, + try_blocks )] extern crate alloc; @@ -21,6 +23,7 @@ pub mod context; pub mod error; #[cfg(feature = "graph")] pub mod graph; +pub mod orchestrator; pub mod postgres; #[cfg(test)] diff --git a/libs/@local/hashql/eval/src/orchestrator/codec/decode/mod.rs b/libs/@local/hashql/eval/src/orchestrator/codec/decode/mod.rs new file mode 100644 index 00000000000..7a96a27c4d2 --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/codec/decode/mod.rs @@ -0,0 +1,406 @@ +use alloc::{rc::Rc, vec}; +use core::alloc::Allocator; + +use hashql_core::{ + algorithms::co_sort, + heap::{CollectIn as _, FromIn as _}, + r#type::{ + TypeId, + environment::Environment, + kind::{Apply, Generic, OpaqueType, PrimitiveType, StructType, TupleType, TypeKind}, + }, +}; +use hashql_mir::interpret::value::{self, Value}; + +use super::{JsonValueKind, JsonValueRef}; +use crate::{ + orchestrator::{ + Indexed, + error::{BridgeError, DecodeError}, + }, + postgres::ColumnDescriptor, +}; + +#[cfg(test)] +mod tests; + +/// Type-directed JSON deserializer that converts column values into interpreter +/// [`Value`]s. +/// +/// Walks the HashQL type tree to determine how each JSON node should be +/// interpreted: primitives map directly, structs expect JSON objects with +/// matching keys, tuples expect arrays of the correct length, unions try each +/// variant in order, and opaque types wrap their inner representation. +/// +/// When the type is unknown ([`Param`], [`Infer`], [`Unknown`]), falls back to +/// `decode_unknown`, which uses JSON structure alone +/// (objects become structs or dicts, arrays become lists, etc.). +/// +/// [`Value`]: hashql_mir::interpret::value::Value +/// [`Param`]: hashql_core::type::kind::TypeKind::Param +/// [`Infer`]: hashql_core::type::kind::TypeKind::Infer +/// [`Unknown`]: hashql_core::type::kind::TypeKind::Unknown +pub struct Decoder<'env, 'heap, A> { + env: &'env Environment<'heap>, + interner: &'env hashql_mir::intern::Interner<'heap>, + + alloc: A, +} + +impl<'env, 'heap, A: Allocator> Decoder<'env, 'heap, A> { + pub const fn new( + env: &'env Environment<'heap>, + interner: &'env hashql_mir::intern::Interner<'heap>, + alloc: A, + ) -> Self { + Self { + env, + interner, + alloc, + } + } + + fn decode_unknown(&self, value: JsonValueRef<'_>) -> Result, DecodeError<'heap>> + where + A: Clone, + { + match value { + JsonValueRef::Null => Ok(Value::Unit), + JsonValueRef::Bool(value) => Ok(Value::Integer(value::Int::from(value))), + JsonValueRef::Number(number) => { + if let Some(value) = number.as_i128() { + Ok(Value::Integer(value::Int::from(value))) + } else { + let value = number + .as_f64() + .ok_or(DecodeError::NumberOutOfRange { expected: None })?; + + Ok(Value::Number(value::Num::from(value))) + } + } + JsonValueRef::String(string) => { + let value = value::Str::from(Rc::from_in(string, self.alloc.clone())); + Ok(Value::String(value)) + } + JsonValueRef::Array(values) => { + // We default in the output to **lists** not tuples. Very important distinction + let mut output = value::List::new(); + + for element in values { + output.push_back(self.decode_unknown(element.into())?); + } + + Ok(Value::List(output)) + } + JsonValueRef::Object(map) => { + if !map.keys().all(|key| { + // Mirrors the implementation of `BaseUrl` parse validation. + if key.len() < 2048 + && let Ok(url) = url::Url::parse(key) + && matches!(url.scheme(), "http" | "https") + && !url.cannot_be_a_base() + && key.ends_with('/') + { + true + } else { + false + } + }) { + let mut dict = value::Dict::new(); + + for (key, value) in map { + let key = self.decode_unknown(JsonValueRef::String(key))?; + let value = self.decode_unknown(value.into())?; + + dict.insert(key, value); + } + + return Ok(Value::Dict(dict)); + } + + let mut fields = Vec::with_capacity_in(map.len(), self.alloc.clone()); + let mut values = Vec::with_capacity_in(map.len(), self.alloc.clone()); + + for (key, value) in map { + let key = self.env.heap.intern_symbol(key); + let value = self.decode_unknown(value.into())?; + + fields.push(key); + values.push(value); + } + + co_sort(&mut fields, &mut values); + let fields = self.interner.symbols.intern_slice(&fields); + + value::Struct::new(fields, values) + .map(Value::Struct) + .ok_or(DecodeError::MalformedConstruction { expected: None }) + } + } + } + + /// Deserializes a JSON value into a typed [`Value`] guided by `type_id`. + /// + /// Recursively walks the type tree: opaque types wrap their inner + /// representation, structs expect JSON objects with matching keys, tuples + /// expect arrays of the correct length, unions try each variant in + /// declaration order, and primitives require exact JSON kind matches. + /// + /// # Errors + /// + /// Returns a [`DecodeError`] when the JSON shape does not match the + /// expected type (wrong kind, missing fields, length mismatches, etc.) + /// or when an unrepresentable type (intersection, closure, never) + /// reaches the decoder. + /// + /// [`Value`]: hashql_mir::interpret::value::Value + #[expect(clippy::too_many_lines)] + pub fn decode( + &self, + type_id: TypeId, + value: JsonValueRef<'_>, + ) -> Result, DecodeError<'heap>> + where + A: Clone, + { + let r#type = self.env.r#type(type_id); + + match r#type.kind { + &TypeKind::Opaque(OpaqueType { name, repr }) => { + let value = self.decode(repr, value)?; + + Ok(Value::Opaque(value::Opaque::new( + name, + Rc::new_in(value, self.alloc.clone()), + ))) + } + TypeKind::Primitive(primitive_type) => match (primitive_type, value) { + (PrimitiveType::Number, JsonValueRef::Number(number)) => { + number.as_f64().map(From::from).map(Value::Number).ok_or( + DecodeError::NumberOutOfRange { + expected: Some(type_id), + }, + ) + } + (PrimitiveType::Integer, JsonValueRef::Number(number)) + if let Some(value) = number.as_i128() => + { + Ok(Value::Integer(value::Int::from(value))) + } + (PrimitiveType::String, JsonValueRef::String(string)) => { + let value = value::Str::from(Rc::from_in(string, self.alloc.clone())); + Ok(Value::String(value)) + } + (PrimitiveType::Null, JsonValueRef::Null) => Ok(Value::Unit), + (PrimitiveType::Boolean, JsonValueRef::Bool(value)) => { + Ok(Value::Integer(value::Int::from(value))) + } + ( + PrimitiveType::Number, + JsonValueRef::Null + | JsonValueRef::Bool(_) + | JsonValueRef::String(_) + | JsonValueRef::Array(_) + | JsonValueRef::Object(_), + ) + | ( + PrimitiveType::Integer, + JsonValueRef::Null + | JsonValueRef::Bool(_) + | JsonValueRef::Number(_) + | JsonValueRef::String(_) + | JsonValueRef::Array(_) + | JsonValueRef::Object(_), + ) + | ( + PrimitiveType::String, + JsonValueRef::Null + | JsonValueRef::Bool(_) + | JsonValueRef::Number(_) + | JsonValueRef::Array(_) + | JsonValueRef::Object(_), + ) + | ( + PrimitiveType::Null, + JsonValueRef::Bool(_) + | JsonValueRef::Number(_) + | JsonValueRef::String(_) + | JsonValueRef::Array(_) + | JsonValueRef::Object(_), + ) + | ( + PrimitiveType::Boolean, + JsonValueRef::Null + | JsonValueRef::Number(_) + | JsonValueRef::String(_) + | JsonValueRef::Array(_) + | JsonValueRef::Object(_), + ) => Err(DecodeError::TypeMismatch { + expected: type_id, + received: JsonValueKind::from(value), + }), + }, + TypeKind::Struct(StructType { fields }) => { + let JsonValueRef::Object(object) = value else { + return Err(DecodeError::TypeMismatch { + expected: type_id, + received: JsonValueKind::from(value), + }); + }; + + if object.len() != fields.len() { + return Err(DecodeError::StructLengthMismatch { + expected: type_id, + expected_length: fields.len(), + received_length: object.len(), + }); + } + + for field in fields.iter() { + if !object.contains_key(field.name.as_str()) { + return Err(DecodeError::MissingField { + expected: type_id, + field: field.name, + }); + } + } + + let names: Vec<_, A> = fields + .iter() + .map(|field| field.name) + .collect_in(self.alloc.clone()); + let names = self.interner.symbols.intern_slice(&names); + let mut values = vec::from_elem_in(Value::Unit, object.len(), self.alloc.clone()); + + // We assume the struct is closed. The length check and per-field + // check above guarantee a bijection between JSON keys and type + // fields, so the position lookup cannot fail. + for (name, value) in object { + let field = fields + .iter() + .position(|field| field.name.as_str() == name) + .unwrap_or_else(|| unreachable!()); + + values[field] = self.decode(fields[field].value, value.into())?; + } + + value::Struct::new(names, values).map(Value::Struct).ok_or( + DecodeError::MalformedConstruction { + expected: Some(type_id), + }, + ) + } + TypeKind::Tuple(TupleType { fields }) => { + let JsonValueRef::Array(array) = value else { + return Err(DecodeError::TypeMismatch { + expected: type_id, + received: JsonValueKind::from(value), + }); + }; + + if array.len() != fields.len() { + return Err(DecodeError::TupleLengthMismatch { + expected: type_id, + expected_length: fields.len(), + received_length: array.len(), + }); + } + + let mut values: Vec<_, A> = Vec::with_capacity_in(array.len(), self.alloc.clone()); + for (element, &field) in array.iter().zip(fields) { + values.push(self.decode(field, element.into())?); + } + + value::Tuple::new(values).map(Value::Tuple).ok_or( + DecodeError::MalformedConstruction { + expected: Some(type_id), + }, + ) + } + + TypeKind::Union(union_type) => { + // Go through *each variant* and try to find the first one that matches + for &variant in &union_type.variants { + if let Ok(value) = self.decode(variant, value) { + return Ok(value); + } + } + + Err(DecodeError::NoMatchingVariant { + expected: type_id, + received: JsonValueKind::from(value), + }) + } + + TypeKind::Intrinsic(hashql_core::r#type::kind::IntrinsicType::List(list)) => { + let JsonValueRef::Array(array) = value else { + return Err(DecodeError::TypeMismatch { + expected: type_id, + received: JsonValueKind::from(value), + }); + }; + + let mut output = value::List::new(); + + for element in array { + output.push_back(self.decode(list.element, element.into())?); + } + + Ok(Value::List(output)) + } + TypeKind::Intrinsic(hashql_core::r#type::kind::IntrinsicType::Dict(dict)) => { + let JsonValueRef::Object(object) = value else { + return Err(DecodeError::TypeMismatch { + expected: type_id, + received: JsonValueKind::from(value), + }); + }; + + let mut output = value::Dict::new(); + + for (key, value) in object { + output.insert( + self.decode(dict.key, JsonValueRef::String(key))?, + self.decode(dict.value, value.into())?, + ); + } + + Ok(Value::Dict(output)) + } + + TypeKind::Intersection(_) => Err(DecodeError::IntersectionType { type_id }), + + &TypeKind::Apply(Apply { + base, + substitutions: _, + }) + | &TypeKind::Generic(Generic { base, arguments: _ }) => self.decode(base, value), + TypeKind::Closure(_) => Err(DecodeError::ClosureType { type_id }), + TypeKind::Never => Err(DecodeError::NeverType { type_id }), + + // We're flying free here, issue a warning, and just try to deserialize using the + // old tactics + // TODO: issue a warning + TypeKind::Param(_) | TypeKind::Infer(_) | TypeKind::Unknown => { + self.decode_unknown(value) + } + } + } + + /// Deserializes a column value into the expected type, or returns an error. + /// + /// The `column` parameter is only used for error reporting; + /// it identifies which result column failed to deserialize. + pub(crate) fn try_decode( + &self, + r#type: TypeId, + value: JsonValueRef<'_>, + column: Indexed, + ) -> Result, BridgeError<'heap>> + where + A: Clone, + { + self.decode(r#type, value) + .map_err(|source| BridgeError::ValueDeserialization { column, source }) + } +} diff --git a/libs/@local/hashql/eval/src/orchestrator/codec/decode/tests.rs b/libs/@local/hashql/eval/src/orchestrator/codec/decode/tests.rs new file mode 100644 index 00000000000..d632d0eec36 --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/codec/decode/tests.rs @@ -0,0 +1,490 @@ +use alloc::{alloc::Global, rc::Rc}; +use core::assert_matches; + +use hashql_core::{ + heap::Heap, + symbol::sym, + r#type::{TypeId, builder::TypeBuilder, environment::Environment}, +}; +use hashql_mir::{ + intern::Interner, + interpret::value::{self, Value}, +}; + +use super::{DecodeError, Decoder, JsonValueRef}; + +fn str_value(content: &str) -> Value<'_, Global> { + Value::String(value::Str::from(Rc::::from(content))) +} + +fn decoder<'env, 'heap>( + env: &'env Environment<'heap>, + interner: &'env Interner<'heap>, +) -> Decoder<'env, 'heap, Global> { + Decoder::new(env, interner, Global) +} + +#[test] +fn primitive_string() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let result = decoder + .decode(types.string(), JsonValueRef::String("hello")) + .expect("should succeed"); + assert_eq!(result, str_value("hello")); +} + +#[test] +fn primitive_integer() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let number = serde_json::Number::from(42); + let result = decoder + .decode(types.integer(), JsonValueRef::Number(&number)) + .expect("should succeed"); + assert_eq!(result, Value::Integer(value::Int::from(42_i128))); +} + +#[test] +fn primitive_number() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let number = serde_json::Number::from_f64(2.72).expect("valid f64"); + let result = decoder + .decode(types.number(), JsonValueRef::Number(&number)) + .expect("should decode number"); + assert_eq!(result, Value::Number(value::Num::from(2.72))); +} + +#[test] +fn primitive_boolean_true() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let result = decoder + .decode(types.boolean(), JsonValueRef::Bool(true)) + .expect("should succeed"); + let Value::Integer(int) = result else { + panic!("expected Value::Integer, got {result:?}"); + }; + assert_eq!(int.as_bool(), Some(true)); +} + +#[test] +fn primitive_boolean_false() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let result = decoder + .decode(types.boolean(), JsonValueRef::Bool(false)) + .expect("should succeed"); + let Value::Integer(int) = result else { + panic!("expected Value::Integer, got {result:?}"); + }; + assert_eq!(int.as_bool(), Some(false)); +} + +#[test] +fn primitive_null() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let result = decoder + .decode(types.null(), JsonValueRef::Null) + .expect("should succeed"); + assert_eq!(result, Value::Unit); +} + +#[test] +fn primitive_type_mismatch() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let result = decoder.decode(types.integer(), JsonValueRef::String("hello")); + assert_matches!(result, Err(DecodeError::TypeMismatch { .. })); +} + +#[test] +fn struct_matching_fields() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let struct_type = types.r#struct([("a", types.integer()), ("b", types.string())]); + + let mut object = serde_json::Map::new(); + object.insert("a".to_owned(), serde_json::Value::Number(1.into())); + object.insert("b".to_owned(), serde_json::Value::String("two".to_owned())); + + let result = decoder + .decode(struct_type, JsonValueRef::Object(&object)) + .expect("should succeed"); + let Value::Struct(fields) = &result else { + panic!("expected Value::Struct, got {result:?}"); + }; + assert_eq!(fields.len(), 2); + assert_eq!(fields.values()[0], Value::Integer(value::Int::from(1_i128))); + assert_eq!(fields.values()[1], str_value("two")); +} + +#[test] +fn struct_missing_field() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let struct_type = types.r#struct([("a", types.integer()), ("b", types.string())]); + + let mut object = serde_json::Map::new(); + object.insert("a".to_owned(), serde_json::Value::Number(1.into())); + + let result = decoder.decode(struct_type, JsonValueRef::Object(&object)); + assert_matches!(result, Err(DecodeError::StructLengthMismatch { .. })); +} + +#[test] +fn struct_extra_field() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let struct_type = types.r#struct([("a", types.integer())]); + + let mut object = serde_json::Map::new(); + object.insert("a".to_owned(), serde_json::Value::Number(1.into())); + object.insert("b".to_owned(), serde_json::Value::Number(2.into())); + + let result = decoder.decode(struct_type, JsonValueRef::Object(&object)); + assert_matches!(result, Err(DecodeError::StructLengthMismatch { .. })); +} + +#[test] +fn tuple_correct_length() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let tuple_type = types.tuple([types.integer(), types.string()]); + + let array = [ + serde_json::Value::Number(1.into()), + serde_json::Value::String("two".to_owned()), + ]; + + let result = decoder + .decode(tuple_type, JsonValueRef::Array(&array)) + .expect("should succeed"); + let Value::Tuple(elements) = &result else { + panic!("expected Value::Tuple, got {result:?}"); + }; + assert_eq!(elements.len().get(), 2); + assert_eq!( + elements.values()[0], + Value::Integer(value::Int::from(1_i128)) + ); + assert_eq!(elements.values()[1], str_value("two")); +} + +#[test] +fn tuple_length_mismatch() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let tuple_type = types.tuple([types.integer(), types.string()]); + let array = [serde_json::Value::Number(1.into())]; + + let result = decoder.decode(tuple_type, JsonValueRef::Array(&array)); + assert_matches!(result, Err(DecodeError::TupleLengthMismatch { .. })); +} + +#[test] +fn union_first_variant_matches() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let union_type = types.union([types.integer(), types.string()]); + let number = serde_json::Number::from(42); + + let result = decoder + .decode(union_type, JsonValueRef::Number(&number)) + .expect("should succeed"); + assert_eq!(result, Value::Integer(value::Int::from(42_i128))); +} + +#[test] +fn union_second_variant_matches() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let union_type = types.union([types.integer(), types.string()]); + + let result = decoder + .decode(union_type, JsonValueRef::String("hello")) + .expect("should succeed"); + assert_eq!(result, str_value("hello")); +} + +#[test] +fn union_no_variant_matches() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let union_type = types.union([types.integer(), types.string()]); + + let result = decoder.decode(union_type, JsonValueRef::Bool(true)); + assert_matches!(result, Err(DecodeError::NoMatchingVariant { .. })); +} + +#[test] +fn opaque_wraps_inner() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let opaque_type = types.opaque(sym::path::Entity, types.string()); + + let result = decoder + .decode(opaque_type, JsonValueRef::String("inner")) + .expect("should succeed"); + let Value::Opaque(opaque) = &result else { + panic!("expected Value::Opaque, got {result:?}"); + }; + assert_eq!(opaque.name(), sym::path::Entity); + assert_eq!(*opaque.value(), str_value("inner")); +} + +#[test] +fn list_intrinsic() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let list_type = types.list(types.integer()); + let array = [ + serde_json::Value::Number(1.into()), + serde_json::Value::Number(2.into()), + ]; + + let result = decoder + .decode(list_type, JsonValueRef::Array(&array)) + .expect("should succeed"); + let Value::List(list) = &result else { + panic!("expected Value::List, got {result:?}"); + }; + assert_eq!(list.len(), 2); + let items: Vec<_> = list.iter().collect(); + assert_eq!(items[0], &Value::Integer(value::Int::from(1_i128))); + assert_eq!(items[1], &Value::Integer(value::Int::from(2_i128))); +} + +#[test] +fn dict_intrinsic() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let dict_type = types.dict(types.string(), types.integer()); + + let mut object = serde_json::Map::new(); + object.insert("x".to_owned(), serde_json::Value::Number(1.into())); + object.insert("y".to_owned(), serde_json::Value::Number(2.into())); + + let result = decoder + .decode(dict_type, JsonValueRef::Object(&object)) + .expect("should succeed"); + let Value::Dict(dict) = &result else { + panic!("expected Value::Dict, got {result:?}"); + }; + assert_eq!(dict.len(), 2); + assert_eq!( + dict.get(&str_value("x")), + Some(&Value::Integer(value::Int::from(1_i128))) + ); + assert_eq!( + dict.get(&str_value("y")), + Some(&Value::Integer(value::Int::from(2_i128))) + ); +} + +#[test] +fn intersection_type_error() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let intersection_type = types.intersection([types.integer(), types.string()]); + + let result = decoder.decode(intersection_type, JsonValueRef::Null); + assert_matches!(result, Err(DecodeError::IntersectionType { .. })); +} + +#[test] +fn closure_type_error() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let closure_type = types.closure([] as [TypeId; 0], types.integer()); + + let result = decoder.decode(closure_type, JsonValueRef::Null); + assert_matches!(result, Err(DecodeError::ClosureType { .. })); +} + +#[test] +fn never_type_error() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let result = decoder.decode(types.never(), JsonValueRef::Null); + assert_matches!(result, Err(DecodeError::NeverType { .. })); +} + +#[test] +fn unknown_type_integer_fallback() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let number = serde_json::Number::from(42); + let result = decoder + .decode(types.unknown(), JsonValueRef::Number(&number)) + .expect("should succeed"); + assert_eq!(result, Value::Integer(value::Int::from(42_i128))); +} + +#[test] +fn unknown_type_float_fallback() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let number = serde_json::Number::from_f64(2.72).expect("should succeed"); + let result = decoder + .decode(types.unknown(), JsonValueRef::Number(&number)) + .expect("should succeed"); + assert_eq!(result, Value::Number(value::Num::from(2.72))); +} + +#[test] +fn unknown_type_array_becomes_list() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let array = [serde_json::Value::Number(1.into())]; + let result = decoder + .decode(types.unknown(), JsonValueRef::Array(&array)) + .expect("should succeed"); + let Value::List(list) = &result else { + panic!("expected Value::List, got {result:?}"); + }; + assert_eq!(list.len(), 1); + let items: Vec<_> = list.iter().collect(); + assert_eq!(items[0], &Value::Integer(value::Int::from(1_i128))); +} + +#[test] +fn unknown_type_non_url_object_becomes_dict() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let mut object = serde_json::Map::new(); + object.insert("key".to_owned(), serde_json::Value::Number(1.into())); + + let result = decoder + .decode(types.unknown(), JsonValueRef::Object(&object)) + .expect("should succeed"); + let Value::Dict(_) = &result else { + panic!("expected Value::Dict, got {result:?}"); + }; +} + +#[test] +fn unknown_type_url_object_becomes_struct() { + let heap = Heap::new(); + let env = Environment::new(&heap); + let interner = Interner::new(&heap); + let types = TypeBuilder::synthetic(&env); + let decoder = decoder(&env, &interner); + + let mut object = serde_json::Map::new(); + object.insert( + "https://example.com/types/property-type/name/".to_owned(), + serde_json::Value::String("Alice".to_owned()), + ); + + let result = decoder + .decode(types.unknown(), JsonValueRef::Object(&object)) + .expect("should succeed"); + let Value::Struct(fields) = &result else { + panic!("expected Value::Struct, got {result:?}"); + }; + assert_eq!(fields.len(), 1); + assert_eq!(fields.values()[0], str_value("Alice")); +} diff --git a/libs/@local/hashql/eval/src/orchestrator/codec/encode/mod.rs b/libs/@local/hashql/eval/src/orchestrator/codec/encode/mod.rs new file mode 100644 index 00000000000..4ab279e3241 --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/codec/encode/mod.rs @@ -0,0 +1,269 @@ +use core::{alloc::Allocator, error, ops::Bound}; + +use bytes::BytesMut; +use hashql_core::{symbol::Symbol, value::Primitive}; +use hashql_mir::{ + body::{local::Local, place::FieldIndex}, + interpret::{ + Inputs, RuntimeError, + suspension::{TemporalAxesInterval, TemporalInterval, Timestamp}, + value::{Int, Value}, + }, +}; +use postgres_protocol::types::RangeBound; +use postgres_types::{Json, ToSql, accepts, to_sql_checked}; +use serde::{ + Serialize, + ser::{SerializeMap as _, SerializeSeq as _}, +}; +use serde_json::value::RawValue; + +use super::{Postgres, Serde}; +use crate::{ + orchestrator::error::BridgeError, + postgres::{ParameterValue, TemporalAxis}, +}; + +#[cfg(test)] +mod tests; + +// timestamp is in ms +impl ToSql for Postgres { + accepts!(TIMESTAMPTZ); + + to_sql_checked!(); + + #[expect(clippy::cast_possible_truncation)] + fn to_sql( + &self, + _: &postgres_types::Type, + out: &mut BytesMut, + ) -> Result> + where + Self: Sized, + { + // The value has been determined via `Date.UTC(2000, 0, 1)` in JS, and is the same as the one that jdbc uses: https://jdbc.postgresql.org/documentation/publicapi/constant-values.html + const BASE: i128 = 946_684_800_000; + + // Our timestamp is milliseconds since Unix epoch (1970-01-01). + // Postgres stores microseconds since 2000-01-01. + let value = ((Int::from(self.0).as_int() - BASE) * 1000) as i64; + + postgres_protocol::types::timestamp_to_sql(value, out); + Ok(postgres_types::IsNull::No) + } +} + +impl ToSql for Postgres { + accepts!(TSTZ_RANGE); + + to_sql_checked!(); + + fn to_sql( + &self, + _: &postgres_types::Type, + out: &mut BytesMut, + ) -> Result> + where + Self: Sized, + { + fn bound_to_sql( + bound: Bound, + buf: &mut BytesMut, + ) -> Result, Box> + { + Ok(match bound { + Bound::Unbounded => RangeBound::Unbounded, + Bound::Included(timestamp) => { + Postgres(timestamp).to_sql(&postgres_types::Type::TIMESTAMPTZ, buf)?; + RangeBound::Inclusive(postgres_protocol::IsNull::No) + } + Bound::Excluded(timestamp) => { + Postgres(timestamp).to_sql(&postgres_types::Type::TIMESTAMPTZ, buf)?; + RangeBound::Exclusive(postgres_protocol::IsNull::No) + } + }) + } + + postgres_protocol::types::range_to_sql( + |buf| bound_to_sql(self.0.start, buf), + |buf| bound_to_sql(self.0.end, buf), + out, + )?; + + Ok(postgres_types::IsNull::No) + } +} + +impl ToSql for Postgres> { + to_sql_checked!(); + + fn to_sql( + &self, + ty: &postgres_types::Type, + out: &mut BytesMut, + ) -> Result> + where + Self: Sized, + { + self.0.as_str().to_sql(ty, out) + } + + fn accepts(ty: &postgres_types::Type) -> bool + where + Self: Sized, + { + <&str>::accepts(ty) + } +} + +impl Serialize for Serde<&Value<'_, A>> { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match &self.0 { + Value::Unit => serializer.serialize_unit(), + Value::Integer(int) => { + if let Some(bool) = int.as_bool() { + serializer.serialize_bool(bool) + } else { + serializer.serialize_i128(int.as_int()) + } + } + Value::Number(num) => serializer.serialize_f64(num.as_f64()), + Value::String(str) => serializer.serialize_str(str.as_str()), + Value::Pointer(_) => Err(serde::ser::Error::custom("pointer value not supported")), + Value::Opaque(opaque) => Self(opaque.value()).serialize(serializer), + Value::Struct(r#struct) => { + let mut inner = serializer.serialize_map(Some(r#struct.len()))?; + + for (field, value) in r#struct.fields().iter().zip(r#struct.values()) { + inner.serialize_entry(&field.as_str(), &Self(value))?; + } + + inner.end() + } + Value::Tuple(tuple) => { + let mut inner = serializer.serialize_seq(Some(tuple.len().get()))?; + + for value in tuple.values() { + inner.serialize_element(&Self(value))?; + } + + inner.end() + } + Value::List(list) => { + let mut inner = serializer.serialize_seq(Some(list.len()))?; + + for value in list.iter() { + inner.serialize_element(&Self(value))?; + } + + inner.end() + } + Value::Dict(dict) => { + let mut inner = serializer.serialize_map(Some(dict.len()))?; + + for (key, value) in dict.iter() { + inner.serialize_entry(&Self(key), &Self(value))?; + } + + inner.end() + } + } + } +} + +/// Serializes a runtime [`Value`] to a JSON [`RawValue`] suitable for use as +/// a PostgreSQL `JSONB` parameter. +/// +/// # Errors +/// +/// Returns [`BridgeError::ValueSerialization`] if the value contains +/// unsupported shapes (e.g. pointer values). +/// +/// [`Value`]: hashql_mir::interpret::value::Value +pub(crate) fn serialize_value<'heap, V: Allocator>( + value: &Value<'heap, V>, +) -> Result>, BridgeError<'heap>> { + let string = serde_json::to_string(&Serde(value)) + .map_err(|source| BridgeError::ValueSerialization { source })?; + + RawValue::from_string(string) + .map_err(|source| BridgeError::ValueSerialization { source }) + .map(Json) +} + +/// Encodes a single query [`Parameter`](crate::postgres::Parameter) into a boxed [`ToSql`] value +/// ready for the PostgreSQL wire protocol. +/// +/// Handles all parameter variants: user inputs (serialized to JSON), literal +/// integers and primitives, interned symbols, captured environment values, +/// and temporal axis intervals. +/// +/// # Errors +/// +/// Returns a [`RuntimeError`] if environment lookup fails or value +/// serialization fails. +/// +/// [`ToSql`]: postgres_types::ToSql +pub(crate) fn encode_parameter_in<'ctx, 'heap, V: Allocator + 'ctx, A: Allocator>( + parameter: &ParameterValue<'heap>, + inputs: &'ctx Inputs<'heap, impl Allocator>, + temporal_axes: &TemporalAxesInterval, + env: impl FnOnce( + Local, + FieldIndex, + ) -> Result<&'ctx Value<'heap, V>, RuntimeError<'heap, BridgeError<'heap>, V>>, + alloc: A, +) -> Result, RuntimeError<'heap, BridgeError<'heap>, V>> { + match parameter { + &ParameterValue::Input(symbol) => { + let value = inputs + .get(symbol) + .map(|value| serialize_value(value).map_err(RuntimeError::Suspension)) + .transpose()?; + Ok(Box::new_in(value, alloc)) + } + ParameterValue::Int(int) => { + let int = int.as_int(); + if let Ok(int) = i64::try_from(int) { + Ok(Box::new_in(int, alloc)) + } else { + // Too large to be represented as an i64, instead use JSONB + Ok(Box::new_in(Json(int), alloc)) + } + } + ParameterValue::Primitive(primitive) => match primitive { + Primitive::Null => Ok(Box::new_in(None::>, alloc)), + &Primitive::Boolean(value) => Ok(Box::new_in(value, alloc)), + Primitive::Float(float) => Ok(Box::new_in(float.as_f64(), alloc)), + Primitive::Integer(integer) => { + if let Some(int) = integer.as_i64() { + Ok(Box::new_in(int, alloc)) + } else { + // Too large to be represented as an i64, because that means we also + // **cannot** serialize it via serde, we fallback to + // using floats. + Ok(Box::new_in(integer.as_f64(), alloc)) + } + } + Primitive::String(value) => Ok(Box::new_in(Box::::from(value.as_str()), alloc)), + }, + &ParameterValue::Symbol(symbol) => Ok(Box::new_in(Postgres(symbol), alloc)), + &ParameterValue::Env(local, field_index) => { + let value = env(local, field_index)?; + let serialized = serialize_value(value).map_err(RuntimeError::Suspension)?; + Ok(Box::new_in(serialized, alloc) as Box) + } + ParameterValue::TemporalAxis(TemporalAxis::Decision) => Ok(Box::new_in( + Postgres(temporal_axes.decision_time.clone()), + alloc, + )), + ParameterValue::TemporalAxis(TemporalAxis::Transaction) => Ok(Box::new_in( + Postgres(temporal_axes.transaction_time.clone()), + alloc, + )), + } +} diff --git a/libs/@local/hashql/eval/src/orchestrator/codec/encode/tests.rs b/libs/@local/hashql/eval/src/orchestrator/codec/encode/tests.rs new file mode 100644 index 00000000000..3c8f326b922 --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/codec/encode/tests.rs @@ -0,0 +1,223 @@ +use alloc::{alloc::Global, rc::Rc}; +use core::ops::Bound; + +use bytes::BytesMut; +use hashql_core::heap::Heap; +use hashql_mir::{ + intern::Interner, + interpret::{ + suspension::{TemporalInterval, Timestamp}, + value::{self, Value}, + }, +}; +use postgres_types::ToSql as _; + +use super::{Postgres, Serde, serialize_value}; + +fn to_json_string(value: &Value<'_, Global>) -> String { + serde_json::to_string(&Serde(value)).expect("should succeed") +} + +#[test] +fn serialize_boolean_true() { + let value = Value::::Integer(value::Int::from(true)); + assert_eq!(to_json_string(&value), "true"); +} + +#[test] +fn serialize_boolean_false() { + let value = Value::::Integer(value::Int::from(false)); + assert_eq!(to_json_string(&value), "false"); +} + +#[test] +fn serialize_integer() { + let value = Value::::Integer(value::Int::from(42_i128)); + assert_eq!(to_json_string(&value), "42"); +} + +#[test] +fn serialize_integer_one_not_as_bool() { + // Int::from(1_i32) has size=128, not size=1. + // Must serialize as numeric 1, not as boolean true. + let value = Value::::Integer(value::Int::from(1_i32)); + assert_eq!(to_json_string(&value), "1"); +} + +#[test] +fn serialize_integer_zero_not_as_bool() { + // Int::from(0_i32) has size=128, not size=1. + // Must serialize as numeric 0, not as boolean false. + let value = Value::::Integer(value::Int::from(0_i32)); + assert_eq!(to_json_string(&value), "0"); +} + +#[test] +fn serialize_number() { + let value = Value::::Number(value::Num::from(2.72)); + assert_eq!(to_json_string(&value), "2.72"); +} + +#[test] +fn serialize_string() { + let value = Value::::String(value::Str::from(Rc::::from("hello"))); + assert_eq!(to_json_string(&value), "\"hello\""); +} + +#[test] +fn serialize_unit() { + let value = Value::::Unit; + assert_eq!(to_json_string(&value), "null"); +} + +#[test] +fn serialize_opaque_unwraps() { + let inner = Value::::Integer(value::Int::from(42_i128)); + let value = Value::Opaque(value::Opaque::new( + hashql_core::symbol::sym::path::Entity, + Rc::new(inner), + )); + assert_eq!(to_json_string(&value), "42"); +} + +#[test] +fn serialize_tuple_as_array() { + let tuple = value::Tuple::new(alloc::vec![ + Value::::Integer(value::Int::from(1_i128)), + Value::Integer(value::Int::from(2_i128)), + ]) + .expect("should succeed"); + + let value = Value::Tuple(tuple); + assert_eq!(to_json_string(&value), "[1,2]"); +} + +#[test] +fn serialize_list() { + let mut list = value::List::::new(); + list.push_back(Value::Integer(value::Int::from(1_i128))); + list.push_back(Value::Integer(value::Int::from(2_i128))); + + let value = Value::List(list); + assert_eq!(to_json_string(&value), "[1,2]"); +} + +#[test] +fn serialize_struct_as_map() { + let heap = Heap::new(); + let interner = Interner::new(&heap); + + let fields = interner + .symbols + .intern_slice(&[heap.intern_symbol("name"), heap.intern_symbol("value")]); + + let values = alloc::vec![ + Value::::String(value::Str::from(Rc::::from("Alice"))), + Value::Integer(value::Int::from(42_i128)), + ]; + + let struct_value = value::Struct::new(fields, values).expect("should succeed"); + let value = Value::Struct(struct_value); + let json = to_json_string(&value); + + let parsed: serde_json::Value = serde_json::from_str(&json).expect("should succeed"); + let object = parsed.as_object().expect("should be an object"); + assert_eq!(object.len(), 2); + assert_eq!(parsed["name"], "Alice"); + assert_eq!(parsed["value"], 42); +} + +#[test] +fn serialize_pointer_fails() { + let value = Value::::Pointer(value::Ptr::new(hashql_mir::def::DefId::new(0))); + let result = serde_json::to_string(&Serde(&value)); + assert!(result.is_err(), "pointer values should not be serializable"); +} + +#[test] +fn serialize_value_produces_raw_json() { + let value = Value::::Integer(value::Int::from(42_i128)); + let result = serialize_value(&value).expect("should succeed"); + assert_eq!(result.0.get(), "42"); +} + +#[test] +fn timestamp_to_sql_known_epoch() { + let mut buffer = BytesMut::new(); + + // 2000-01-01T00:00:00Z in milliseconds since Unix epoch = 946684800000 + let timestamp = Timestamp::from(value::Int::from(946_684_800_000_i128)); + Postgres(timestamp) + .to_sql(&postgres_types::Type::TIMESTAMPTZ, &mut buffer) + .expect("should succeed"); + + // Should encode as 0 microseconds since the postgres epoch (2000-01-01) + assert_eq!(buffer.len(), 8); + #[expect( + clippy::big_endian_bytes, + reason = "postgres wire format is big-endian" + )] + let encoded = i64::from_be_bytes(buffer[..8].try_into().expect("should succeed")); + assert_eq!(encoded, 0); +} + +#[test] +fn timestamp_to_sql_one_second_after_epoch() { + let mut buffer = BytesMut::new(); + + // 2000-01-01T00:00:01Z = 946684801000 ms since Unix epoch + let timestamp = Timestamp::from(value::Int::from(946_684_801_000_i128)); + Postgres(timestamp) + .to_sql(&postgres_types::Type::TIMESTAMPTZ, &mut buffer) + .expect("should succeed"); + + #[expect( + clippy::big_endian_bytes, + reason = "postgres wire format is big-endian" + )] + let encoded = i64::from_be_bytes(buffer[..8].try_into().expect("should succeed")); + // 1 second = 1_000_000 microseconds + assert_eq!(encoded, 1_000_000); +} + +#[test] +fn temporal_interval_point_encodes() { + let mut buffer = BytesMut::new(); + + let timestamp = Timestamp::from(value::Int::from(946_684_800_000_i128)); + let interval = TemporalInterval { + start: Bound::Included(timestamp), + end: Bound::Included(timestamp), + }; + + Postgres(interval) + .to_sql(&postgres_types::Type::TSTZ_RANGE, &mut buffer) + .expect("should succeed"); + + // Range wire format: 1 byte flags + lower bound (4 byte len + 8 byte data) + + // upper bound (4 byte len + 8 byte data) = 25 bytes for two inclusive timestamps + assert_eq!(buffer.len(), 25); + + // Flags byte: bit 1 (has lower) | bit 2 (has upper) | bit 2 (lower inclusive) | + // bit 3 (upper inclusive) = 0x06 for [inclusive, inclusive] + // (postgres_protocol range encoding details) + let flags = buffer[0]; + assert_ne!(flags, 0, "flags should indicate both bounds are present"); +} + +#[test] +fn temporal_interval_unbounded_encodes() { + let mut buffer = BytesMut::new(); + + let interval = TemporalInterval { + start: Bound::Unbounded, + end: Bound::Unbounded, + }; + + Postgres(interval) + .to_sql(&postgres_types::Type::TSTZ_RANGE, &mut buffer) + .expect("should succeed"); + + // Fully unbounded range: only 1 byte for flags + assert_eq!(buffer.len(), 1); +} diff --git a/libs/@local/hashql/eval/src/orchestrator/codec/mod.rs b/libs/@local/hashql/eval/src/orchestrator/codec/mod.rs new file mode 100644 index 00000000000..e9ca9aec3ce --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/codec/mod.rs @@ -0,0 +1,103 @@ +//! JSON codec for converting between interpreter [`Value`]s and the PostgreSQL +//! wire format. +//! +//! - `decode`: deserializes JSON column values (from `tokio_postgres` rows) into typed [`Value`]s, +//! guided by the HashQL type system. +//! - `encode`: serializes runtime [`Value`]s and query parameters into forms that `tokio_postgres` +//! can send to the database (via [`ToSql`]). +//! +//! The [`JsonValueRef`] type provides a borrowed view over `serde_json::Value` +//! that avoids cloning during decode, while [`JsonValueKind`] is a data-free +//! tag used in error reporting. +//! +//! [`Value`]: hashql_mir::interpret::value::Value +//! [`ToSql`]: postgres_types::ToSql + +pub(crate) mod decode; +pub(crate) mod encode; + +pub use self::decode::Decoder; +pub use crate::orchestrator::error::DecodeError; + +/// Newtype wrapper that provides [`ToSql`](postgres_types::ToSql) +/// implementations for types that need custom PostgreSQL wire encoding. +#[derive(Debug)] +pub(crate) struct Postgres(pub T); + +/// Newtype wrapper that provides [`Serialize`](serde::Serialize) +/// implementations for types that need custom JSON serialization. +/// +/// Wrap a `&Value` in `Serde` to serialize it to JSON using the interpreter's +/// value representation rules: booleans serialize as JSON booleans (not +/// integers), opaques unwrap to their inner value, structs serialize as +/// objects with field names as keys. +#[derive(Debug)] +pub struct Serde(pub T); + +/// Borrowed view over a JSON value, avoiding clones during decode. +/// +/// Mirrors the variants of [`serde_json::Value`] but holds references +/// instead of owned data. Constructed from `&serde_json::Value` via the +/// [`From`] impl, or directly for single-typed columns (e.g. +/// `JsonValueRef::String(&str)` for a `TEXT` column). +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum JsonValueRef<'value> { + Null, + Bool(bool), + Number(&'value serde_json::Number), + String(&'value str), + Array(&'value [serde_json::Value]), + Object(&'value serde_json::Map), +} + +/// The kind of a JSON value, without carrying the actual data. +/// +/// Used in error reporting to describe what was received when a decode fails. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum JsonValueKind { + Null, + Bool, + Number, + String, + Array, + Object, +} + +impl JsonValueKind { + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Null => "null", + Self::Bool => "boolean", + Self::Number => "number", + Self::String => "string", + Self::Array => "array", + Self::Object => "object", + } + } +} + +impl From> for JsonValueKind { + fn from(value: JsonValueRef<'_>) -> Self { + match value { + JsonValueRef::Null => Self::Null, + JsonValueRef::Bool(_) => Self::Bool, + JsonValueRef::Number(_) => Self::Number, + JsonValueRef::String(_) => Self::String, + JsonValueRef::Array(_) => Self::Array, + JsonValueRef::Object(_) => Self::Object, + } + } +} + +impl<'value> From<&'value serde_json::Value> for JsonValueRef<'value> { + fn from(value: &'value serde_json::Value) -> Self { + match value { + serde_json::Value::Null => JsonValueRef::Null, + &serde_json::Value::Bool(value) => JsonValueRef::Bool(value), + serde_json::Value::Number(number) => JsonValueRef::Number(number), + serde_json::Value::String(string) => JsonValueRef::String(string.as_str()), + serde_json::Value::Array(array) => JsonValueRef::Array(array), + serde_json::Value::Object(object) => JsonValueRef::Object(object), + } + } +} diff --git a/libs/@local/hashql/eval/src/orchestrator/error.rs b/libs/@local/hashql/eval/src/orchestrator/error.rs new file mode 100644 index 00000000000..b19b096192e --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/error.rs @@ -0,0 +1,718 @@ +//! Errors that occur while fulfilling [`GraphRead`] suspensions. +//! +//! These are internal runtime errors: failures in compiled query execution, +//! row decoding, or parameter encoding. The user wrote HashQL, not SQL; if +//! the bridge fails, it indicates a bug in the compiler or runtime. +//! +//! [`GraphRead`]: hashql_mir::body::terminator::GraphRead + +use alloc::string::String; + +use hashql_core::{ + pretty::{Formatter, RenderOptions}, + span::SpanId, + symbol::Symbol, + r#type::{TypeFormatter, TypeFormatterOptions, TypeId, environment::Environment}, +}; +use hashql_diagnostics::{ + Diagnostic, Label, category::TerminalDiagnosticCategory, diagnostic::Message, + severity::Severity, +}; +use hashql_mir::{ + body::{basic_block::BasicBlockId, local::Local}, + def::DefId, + interpret::error::{ + InterpretDiagnostic, InterpretDiagnosticCategory, SuspensionDiagnosticCategory, + }, +}; + +use super::{Indexed, codec::JsonValueKind}; +use crate::postgres::ColumnDescriptor; + +const QUERY_EXECUTION: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "query-execution", + name: "Query Execution", +}; + +const ROW_HYDRATION: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "row-hydration", + name: "Row Hydration", +}; + +const PARAMETER_ENCODING: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "parameter-encoding", + name: "Parameter Encoding", +}; + +const VALUE_DESERIALIZATION: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "value-deserialization", + name: "Value Deserialization", +}; + +const CONTINUATION_DESERIALIZATION: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "continuation-deserialization", + name: "Continuation Deserialization", +}; + +const INVALID_CONTINUATION_BLOCK_ID: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "invalid-continuation-block-id", + name: "Invalid Continuation Block ID", +}; + +const INVALID_CONTINUATION_LOCAL: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "invalid-continuation-local", + name: "Invalid Continuation Local", +}; + +const QUERY_LOOKUP: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "query-lookup", + name: "Query Lookup", +}; + +const INCOMPLETE_CONTINUATION: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "incomplete-continuation", + name: "Incomplete Continuation", +}; + +const MISSING_EXECUTION_RESIDUAL: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "missing-execution-residual", + name: "Missing Execution Residual", +}; + +const INVALID_FILTER_RETURN: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "invalid-filter-return", + name: "Invalid Filter Return", +}; + +const VALUE_SERIALIZATION: TerminalDiagnosticCategory = TerminalDiagnosticCategory { + id: "value-serialization", + name: "Value Serialization", +}; + +const fn category(terminal: &'static TerminalDiagnosticCategory) -> InterpretDiagnosticCategory { + InterpretDiagnosticCategory::Suspension(SuspensionDiagnosticCategory(terminal)) +} + +/// Errors that occur while decoding a JSON value into a typed [`Value`]. +/// +/// Each variant carries the leaf [`TypeId`] at the point of failure: the +/// specific type in the tree where decoding broke. The caller provides the +/// top-level type context (e.g. via a column descriptor), so the diagnostic +/// can show both *what* the column was supposed to produce and *where* in the +/// type tree it went wrong. +/// +/// [`Value`]: hashql_mir::interpret::value::Value +#[derive(Debug, Copy, Clone)] +pub enum DecodeError<'heap> { + /// The JSON value kind does not match the expected type. + /// + /// For example, the decoder expected a JSON object (for a struct) but + /// received a number. + TypeMismatch { + /// The leaf type that was being decoded when the mismatch occurred. + expected: TypeId, + /// The JSON value kind that was actually received. + received: JsonValueKind, + }, + + /// A required field is missing from a JSON object when decoding a struct. + MissingField { + /// The struct type being decoded. + expected: TypeId, + /// The name of the missing field. + field: Symbol<'heap>, + }, + + /// The JSON object has a different number of keys than the struct expects. + StructLengthMismatch { + /// The struct type being decoded. + expected: TypeId, + /// The number of fields the struct type requires. + expected_length: usize, + /// The number of keys in the JSON object. + received_length: usize, + }, + + /// The JSON array length does not match the expected tuple arity. + TupleLengthMismatch { + /// The tuple type being decoded. + expected: TypeId, + /// The number of elements the tuple type requires. + expected_length: usize, + /// The number of elements in the JSON array. + received_length: usize, + }, + + /// None of a union type's variants could decode the value. + NoMatchingVariant { + /// The union type being decoded. + expected: TypeId, + /// The JSON value kind that no variant accepted. + received: JsonValueKind, + }, + + /// A JSON number could not be represented as `f64`. + /// + /// This only occurs when `serde_json`'s `arbitrary_precision` feature is + /// active and the number overflows to infinity or NaN. The variant + /// optionally carries the type being decoded, absent when the failure + /// occurs inside the untyped fallback path. + NumberOutOfRange { + /// The numeric type that was being decoded, if known. + expected: Option, + }, + + /// An internal invariant was violated during value construction. + /// + /// This indicates a bug in the decoder itself, for example constructing + /// a struct with mismatched field/value counts, or an empty tuple. The + /// variant optionally carries the type, absent when the failure occurs + /// inside the untyped fallback path. + MalformedConstruction { + /// The type being constructed when the invariant was violated, if known. + expected: Option, + }, + + /// An intersection type reached the decoder. + /// + /// Intersection types cannot be safely represented as JSON, so the + /// placement pass should have rejected any query that would require + /// deserializing one from a postgres result. + IntersectionType { + /// The intersection type that was encountered. + type_id: TypeId, + }, + + /// A closure type reached the decoder. + /// + /// Closures are opaque runtime values that cannot be serialized or + /// transported through postgres. The placement pass should have + /// rejected any query that would require deserializing a closure. + ClosureType { + /// The closure type that was encountered. + type_id: TypeId, + }, + + /// A never type (`!`) reached the decoder. + /// + /// The never type is uninhabited: no value of type `!` can exist, so + /// attempting to deserialize one is always a bug. + NeverType { + /// The never type that was encountered. + type_id: TypeId, + }, +} + +/// Errors from the bridge while fulfilling a [`GraphRead`] suspension. +/// +/// All variants represent internal failures. The user wrote HashQL, not SQL; +/// if the bridge fails, the compiler or runtime produced something invalid. +/// +/// [`GraphRead`]: hashql_mir::body::terminator::GraphRead +#[derive(Debug)] +pub enum BridgeError<'heap> { + /// The compiled SQL query was rejected by PostgreSQL. + /// + /// Carries the generated SQL so the diagnostic can show exactly what + /// the compiler produced. + QueryExecution { + /// The SQL statement that was sent to the database. + sql: String, + /// The rejection error from the database. + source: tokio_postgres::Error, + }, + + /// A row returned by PostgreSQL could not be decoded into a value. + /// + /// The query executed successfully, but a column in the result set has a + /// type the runtime does not expect, indicating a mismatch between what + /// the SQL lowering pass promised and what the database actually returned. + RowHydration { + /// The column descriptor identifying what this column represents. + column: Indexed, + /// The database error describing the type mismatch. + source: tokio_postgres::Error, + }, + + /// A decoded column value does not match the expected type for its entity path. + /// + /// The column decoded successfully at the PostgreSQL wire level, but the + /// resulting value could not be deserialized into the HashQL type the + /// runtime expects for this storage location. This indicates the SQL + /// lowering pass produced a query whose result shape does not match the + /// entity schema. + ValueDeserialization { + /// The column descriptor identifying what this column represents. + column: Indexed, + /// The specific decode failure. + source: DecodeError<'heap>, + }, + + /// A continuation local could not be deserialized back into its expected type. + /// + /// Continuation locals are values that were serialized into JSON by the SQL + /// lowering pass and returned alongside query results so the interpreter can + /// resume execution. If one of these cannot be decoded, the lowering pass + /// produced a continuation whose shape the runtime cannot reconstruct. + ContinuationDeserialization { + /// The definition containing the continuation. + body: DefId, + /// The local variable that failed to deserialize. + local: Local, + /// The specific decode failure. + source: DecodeError<'heap>, + }, + + /// A query parameter could not be serialized for PostgreSQL. + /// + /// The SQL lowering pass emitted a parameter that the encoder does not + /// know how to serialize into the wire format the database expects. + ParameterEncoding { + /// The zero-based index of the parameter that failed (`$1` = index 0). + parameter: usize, + /// The encoding error. + source: Box, + }, + + /// A continuation block ID returned by PostgreSQL is out of range. + /// + /// The SQL lowering pass encodes the target basic block as an integer in the + /// query result. A negative value cannot represent a valid [`BasicBlockId`] + /// and indicates a bug in the lowering pass. + InvalidContinuationBlockId { + /// The definition containing the continuation. + body: DefId, + /// The invalid block ID value returned by PostgreSQL. + block_id: i32, + }, + + /// A continuation local index returned by PostgreSQL is out of range. + /// + /// The SQL lowering pass encodes local variable indices as integers in the + /// query result. A negative value cannot represent a valid [`Local`] and + /// indicates a bug in the lowering pass. + /// + /// [`Local`]: hashql_mir::body::local::Local + InvalidContinuationLocal { + /// The definition containing the continuation. + body: DefId, + /// The invalid local value returned by PostgreSQL. + local: i32, + }, + + /// No prepared query exists for this graph read location. + /// + /// Every [`GraphRead`] terminator in the MIR should have a corresponding + /// compiled query produced by the SQL lowering pass. + /// + /// [`GraphRead`]: hashql_mir::body::terminator::GraphRead + QueryLookup { + /// The definition containing the graph read. + body: DefId, + /// The basic block containing the graph read terminator. + block: BasicBlockId, + }, + + /// A continuation state was not fully populated before finishing. + /// + /// When a row contains a non-null continuation target, the locals and values + /// columns must also be present. A missing or null field indicates the SQL + /// lowering pass produced a continuation with an incomplete column set. + IncompleteContinuation { + /// The definition containing the continuation. + body: DefId, + /// The name of the field that was missing or null. + field: &'static str, + }, + + /// No execution residual was found for a definition that requires one. + /// + /// The execution analysis pass should produce island mappings for every + /// definition that appears in a filter chain. A missing residual indicates + /// the execution pipeline did not analyze this definition. + MissingExecutionResidual { + /// The definition that has no execution residual. + body: DefId, + }, + + /// A filter body returned a non-boolean value. + /// + /// Filter bodies must evaluate to a boolean. If the interpreter produces + /// a value that is not representable as a boolean, the HIR type checking + /// or lowering pass has a bug. + InvalidFilterReturn { + /// The filter definition that returned a non-boolean. + body: DefId, + }, + + /// A runtime value could not be serialized to JSON. + /// + /// Serialization failures indicate a bug in the encoder or an unsupported + /// value shape (e.g. pointer values). + ValueSerialization { + /// The serialization error from `serde_json`. + source: serde_json::Error, + }, +} + +impl<'heap> BridgeError<'heap> { + pub fn into_diagnostic(self, span: SpanId, env: &Environment<'heap>) -> InterpretDiagnostic { + match self { + Self::QueryExecution { sql, source } => query_execution(span, &sql, &source), + Self::RowHydration { column, source } => row_hydration(span, column, &source), + Self::ValueDeserialization { column, source } => { + value_deserialization(span, column, &source, env) + } + Self::ContinuationDeserialization { + body, + local, + source, + } => continuation_deserialization(span, body, local, &source, env), + Self::InvalidContinuationBlockId { body, block_id } => { + invalid_continuation_block_id(span, body, block_id) + } + Self::InvalidContinuationLocal { body, local } => { + invalid_continuation_local(span, body, local) + } + Self::ParameterEncoding { parameter, source } => { + parameter_encoding(span, parameter, &*source) + } + Self::QueryLookup { body, block } => query_lookup(span, body, block), + Self::IncompleteContinuation { body, field } => { + incomplete_continuation(span, body, field) + } + Self::MissingExecutionResidual { body } => missing_execution_residual(span, body), + Self::InvalidFilterReturn { body } => invalid_filter_return(span, body), + Self::ValueSerialization { source } => value_serialization(span, &source), + } + } +} + +fn query_execution(span: SpanId, sql: &str, error: &tokio_postgres::Error) -> InterpretDiagnostic { + let mut diagnostic = Diagnostic::new(category(&QUERY_EXECUTION), Severity::Bug).primary( + Label::new(span, "compiled query was rejected by the database"), + ); + + diagnostic.add_message(Message::note(format!("generated SQL: {sql}"))); + + diagnostic.add_message(Message::note(format!("the database reported: {error}"))); + + diagnostic.add_message(Message::help( + "the SQL lowering pass should produce queries that the database accepts", + )); + + diagnostic +} + +fn row_hydration( + span: SpanId, + Indexed { + index, + value: column, + }: Indexed, + source: &tokio_postgres::Error, +) -> InterpretDiagnostic { + let mut diagnostic = + Diagnostic::new(category(&ROW_HYDRATION), Severity::Bug).primary(Label::new( + span, + format!("cannot decode result column {index} ({column})"), + )); + + diagnostic.add_message(Message::note(format!("the database reported: {source}"))); + + diagnostic.add_message(Message::help( + "the SQL lowering pass should produce queries whose result types the runtime can decode", + )); + + diagnostic +} + +/// Adds notes describing a [`DecodeError`] to a diagnostic. +fn add_decode_error_notes( + diagnostic: &mut InterpretDiagnostic, + source: &DecodeError<'_>, + env: &Environment<'_>, +) { + let fmt = Formatter::new(env.heap); + let mut type_fmt = TypeFormatter::new(&fmt, env, TypeFormatterOptions::default()); + let render = RenderOptions::default(); + + match source { + DecodeError::TypeMismatch { expected, received } => { + diagnostic.add_message(Message::note(format!( + "expected `{}` but received JSON {}", + type_fmt.render(*expected, render), + received.as_str(), + ))); + } + DecodeError::MissingField { expected, field } => { + diagnostic.add_message(Message::note(format!( + "field `{field}` is missing from the JSON object when decoding `{}`", + type_fmt.render(*expected, render), + ))); + } + DecodeError::StructLengthMismatch { + expected, + expected_length, + received_length, + } => { + diagnostic.add_message(Message::note(format!( + "expected {expected_length} fields for `{}` but received {received_length}", + type_fmt.render(*expected, render), + ))); + } + DecodeError::TupleLengthMismatch { + expected, + expected_length, + received_length, + } => { + diagnostic.add_message(Message::note(format!( + "expected {expected_length} elements for `{}` but received {received_length}", + type_fmt.render(*expected, render), + ))); + } + DecodeError::NoMatchingVariant { expected, received } => { + diagnostic.add_message(Message::note(format!( + "no variant of `{}` could decode JSON {}", + type_fmt.render(*expected, render), + received.as_str(), + ))); + } + DecodeError::NumberOutOfRange { expected } => { + if let Some(expected) = expected { + diagnostic.add_message(Message::note(format!( + "JSON number is out of range for `{}`", + type_fmt.render(*expected, render), + ))); + } else { + diagnostic.add_message(Message::note( + "JSON number is out of range and cannot be represented as a floating-point \ + value", + )); + } + } + DecodeError::MalformedConstruction { expected } => { + if let Some(expected) = expected { + diagnostic.add_message(Message::note(format!( + "internal invariant violated while constructing `{}`", + type_fmt.render(*expected, render), + ))); + } else { + diagnostic.add_message(Message::note( + "internal invariant violated during value construction", + )); + } + } + DecodeError::IntersectionType { type_id } => { + diagnostic.add_message(Message::note(format!( + "intersection type `{}` cannot be safely represented as JSON", + type_fmt.render(*type_id, render), + ))); + diagnostic.add_message(Message::help( + "the placement pass should reject queries that require deserializing intersection \ + types from postgres", + )); + } + DecodeError::ClosureType { type_id } => { + diagnostic.add_message(Message::note(format!( + "closure type `{}` cannot be transported through postgres", + type_fmt.render(*type_id, render), + ))); + diagnostic.add_message(Message::help( + "the placement pass should reject queries that require deserializing closures \ + from postgres", + )); + } + DecodeError::NeverType { type_id } => { + diagnostic.add_message(Message::note(format!( + "the never type `{}` is uninhabited and cannot have a value", + type_fmt.render(*type_id, render), + ))); + diagnostic.add_message(Message::help( + "the MIR pipeline should prevent never types from reaching evaluation", + )); + } + } +} + +fn value_deserialization( + span: SpanId, + Indexed { + index, + value: column, + }: Indexed, + source: &DecodeError<'_>, + env: &Environment<'_>, +) -> InterpretDiagnostic { + let mut diagnostic = + Diagnostic::new(category(&VALUE_DESERIALIZATION), Severity::Bug).primary(Label::new( + span, + format!("cannot deserialize result column {index} ({column})"), + )); + + add_decode_error_notes(&mut diagnostic, source, env); + + diagnostic.add_message(Message::help( + "the SQL lowering pass should produce queries whose result types match the entity schema", + )); + + diagnostic +} + +fn continuation_deserialization( + span: SpanId, + body: DefId, + local: Local, + source: &DecodeError<'_>, + env: &Environment<'_>, +) -> InterpretDiagnostic { + let mut diagnostic = Diagnostic::new(category(&CONTINUATION_DESERIALIZATION), Severity::Bug) + .primary(Label::new( + span, + format!("cannot deserialize continuation local {local} in definition {body}"), + )); + + add_decode_error_notes(&mut diagnostic, source, env); + + diagnostic.add_message(Message::help( + "the SQL lowering pass should produce continuations whose types the runtime can \ + reconstruct", + )); + + diagnostic +} + +fn invalid_continuation_block_id(span: SpanId, body: DefId, block_id: i32) -> InterpretDiagnostic { + let mut diagnostic = + Diagnostic::new(category(&INVALID_CONTINUATION_BLOCK_ID), Severity::Bug).primary( + Label::new(span, "continuation returned an invalid block ID"), + ); + + diagnostic.add_message(Message::note(format!( + "definition {body} returned block ID {block_id}, which cannot represent a valid block" + ))); + + diagnostic.add_message(Message::help( + "the SQL lowering pass should produce non-negative block IDs for continuations", + )); + + diagnostic +} + +fn invalid_continuation_local(span: SpanId, body: DefId, local: i32) -> InterpretDiagnostic { + let mut diagnostic = Diagnostic::new(category(&INVALID_CONTINUATION_LOCAL), Severity::Bug) + .primary(Label::new(span, "continuation returned an invalid local")); + + diagnostic.add_message(Message::note(format!( + "definition {body} returned local {local}, which cannot represent a valid local" + ))); + + diagnostic.add_message(Message::help( + "the SQL lowering pass should produce non-negative local indices for continuations", + )); + + diagnostic +} + +fn parameter_encoding( + span: SpanId, + parameter: usize, + error: &(dyn core::error::Error + Send + Sync), +) -> InterpretDiagnostic { + let mut diagnostic = + Diagnostic::new(category(&PARAMETER_ENCODING), Severity::Bug).primary(Label::new( + span, + format!( + "cannot encode parameter ${} for the database", + parameter + 1 + ), + )); + + diagnostic.add_message(Message::note(format!("the encoder reported: {error}"))); + + diagnostic.add_message(Message::help( + "the SQL lowering pass should only emit parameter types the encoder supports", + )); + + diagnostic +} + +fn query_lookup(span: SpanId, body: DefId, block: BasicBlockId) -> InterpretDiagnostic { + let mut diagnostic = Diagnostic::new(category(&QUERY_LOOKUP), Severity::Bug).primary( + Label::new(span, "no compiled query found for this data access"), + ); + + diagnostic.add_message(Message::note(format!( + "missing query for definition {body} at block {block}" + ))); + + diagnostic.add_message(Message::help( + "the SQL lowering pass should produce a compiled query for every data access", + )); + + diagnostic +} + +fn incomplete_continuation(span: SpanId, body: DefId, field: &str) -> InterpretDiagnostic { + let mut diagnostic = Diagnostic::new(category(&INCOMPLETE_CONTINUATION), Severity::Bug) + .primary(Label::new( + span, + "continuation state is missing required columns", + )); + + diagnostic.add_message(Message::note(format!( + "continuation for definition {body} has a non-null target but `{field}` was not populated" + ))); + + diagnostic.add_message(Message::help( + "the SQL lowering pass should produce all continuation columns together", + )); + + diagnostic +} + +fn missing_execution_residual(span: SpanId, body: DefId) -> InterpretDiagnostic { + let mut diagnostic = Diagnostic::new(category(&MISSING_EXECUTION_RESIDUAL), Severity::Bug) + .primary(Label::new( + span, + "no execution residual found for this definition", + )); + + diagnostic.add_message(Message::note(format!( + "definition {body} appears in a filter chain but has no island mapping" + ))); + + diagnostic.add_message(Message::help( + "the execution analysis pass should produce island mappings for all filter definitions", + )); + + diagnostic +} + +fn invalid_filter_return(span: SpanId, body: DefId) -> InterpretDiagnostic { + let mut diagnostic = Diagnostic::new(category(&INVALID_FILTER_RETURN), Severity::Bug) + .primary(Label::new(span, "filter body returned a non-boolean value")); + + diagnostic.add_message(Message::note(format!( + "filter definition {body} must evaluate to a boolean" + ))); + + diagnostic.add_message(Message::help( + "the HIR type checking pass should ensure filter bodies return a boolean", + )); + + diagnostic +} + +fn value_serialization(span: SpanId, error: &serde_json::Error) -> InterpretDiagnostic { + let mut diagnostic = Diagnostic::new(category(&VALUE_SERIALIZATION), Severity::Bug) + .primary(Label::new(span, "cannot serialize runtime value to JSON")); + + diagnostic.add_message(Message::note(format!("serialization failed: {error}"))); + + diagnostic.add_message(Message::help( + "all values passed to the database should be serializable", + )); + + diagnostic +} diff --git a/libs/@local/hashql/eval/src/orchestrator/events.rs b/libs/@local/hashql/eval/src/orchestrator/events.rs new file mode 100644 index 00000000000..e2c6b4cd3b6 --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/events.rs @@ -0,0 +1,158 @@ +//! Opt-in event tracing for the orchestrator execution pipeline. +//! +//! The orchestrator emits [`Event`]s at key decision points: query dispatch, +//! row hydration, filter evaluation, island transitions, and result collection. +//! An [`EventLog`] sink receives them. The default `()` implementation compiles +//! to a no-op with zero runtime cost. [`AppendEventLog`] collects events into +//! a [`Vec`] for test assertions. +//! +//! # Design +//! +//! All [`Event`] variants are `Copy`. This guarantees that the `()` sink +//! optimizes away completely: no allocation, no drop glue, no residual code +//! in the dispatch loop. Non-`Copy` payloads (e.g. [`String`]) would prevent +//! LLVM from eliminating dead event construction even through a no-op sink. +//! +//! [`EventLog::log`] takes `&self` rather than `&mut self` so that events can +//! be emitted through shared borrows of the [`Orchestrator`]. Interior +//! mutability is handled by the sink implementation ([`AppendEventLog`] uses +//! a [`LocalLock`]). +//! +//! [`Orchestrator`]: super::Orchestrator + +use core::{ + fmt::{self, Display}, + mem, +}; + +use hashql_core::sync::lock::LocalLock; +use hashql_mir::{ + body::basic_block::BasicBlockId, + def::DefId, + pass::execution::{IslandId, TargetId}, +}; + +/// A single orchestrator execution event. +/// +/// Each variant captures the structured data needed to reconstruct what +/// happened at a particular point in the execution pipeline. Formatting +/// is the listener's responsibility; use the [`Display`] implementation +/// for human-readable output. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum Event { + /// SQL query dispatched to PostgreSQL. + QueryExecuted { body: DefId, block: BasicBlockId }, + /// A result row was received from PostgreSQL. + RowReceived, + + /// A filter body started evaluating for the current row. + FilterStarted { body: DefId }, + /// The filter accepted the current row. + FilterAccepted { body: DefId }, + /// The filter rejected the current row. + FilterRejected { body: DefId }, + + /// Entered an execution island within a filter body. + IslandEntered { + body: DefId, + island: IslandId, + target: TargetId, + }, + /// Postgres continuation state was flushed into the callstack. + ContinuationFlushed { body: DefId, island: IslandId }, + /// Postgres island had no continuation state (implicit true). + ContinuationImplicitTrue { body: DefId }, + + /// A row survived all filters and was added to the output. + RowAccepted, + /// A row was rejected by the filter chain. + RowRejected, +} + +impl Display for Event { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::QueryExecuted { body, block } => { + write!(f, "query executed: body {body}, block {block}") + } + Self::RowReceived => f.write_str("row received"), + Self::FilterStarted { body } => write!(f, "filter started: body {body}"), + Self::FilterAccepted { body } => write!(f, "filter accepted: body {body}"), + Self::FilterRejected { body } => write!(f, "filter rejected: body {body}"), + Self::IslandEntered { + body, + island, + target, + } => write!( + f, + "island entered: body {body}, island {island}, target {target}" + ), + Self::ContinuationFlushed { body, island } => { + write!(f, "continuation flushed: body {body}, island {island}") + } + Self::ContinuationImplicitTrue { body } => { + write!(f, "continuation implicit true: body {body}") + } + Self::RowAccepted => f.write_str("row accepted"), + Self::RowRejected => f.write_str("row rejected"), + } + } +} + +/// Receiver for orchestrator [`Event`]s. +/// +/// Implement this trait to observe execution decisions without modifying +/// the orchestrator's control flow. The method takes `&self` to allow +/// emission through shared borrows; implementations that need mutation +/// should use interior mutability (e.g. [`LocalLock`]). +/// +/// The `()` implementation discards all events and compiles to a no-op. +pub trait EventLog { + /// Records a single event. + fn log(&self, event: Event); +} + +impl EventLog for () { + #[inline(always)] + fn log(&self, _: Event) {} +} + +impl EventLog for &T { + #[inline] + fn log(&self, event: Event) { + T::log(self, event); + } +} + +/// An [`EventLog`] that appends events to an internal [`Vec`]. +/// +/// Uses a [`LocalLock`] for interior mutability so that [`log`](EventLog::log) +/// can be called through `&self`. Retrieve collected events with [`take`](Self::take), +/// which drains the buffer. +#[derive(Debug)] +pub struct AppendEventLog(LocalLock>); + +impl AppendEventLog { + /// Creates an empty event log. + #[must_use] + pub const fn new() -> Self { + Self(LocalLock::new(Vec::new())) + } + + /// Drains and returns all collected events, leaving the buffer empty. + pub fn take(&self) -> Vec { + mem::take(&mut *self.0.lock()) + } +} + +impl Default for AppendEventLog { + fn default() -> Self { + Self::new() + } +} + +impl EventLog for AppendEventLog { + fn log(&self, event: Event) { + self.0.lock().push(event); + } +} diff --git a/libs/@local/hashql/eval/src/orchestrator/mod.rs b/libs/@local/hashql/eval/src/orchestrator/mod.rs new file mode 100644 index 00000000000..978193b83ba --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/mod.rs @@ -0,0 +1,296 @@ +//! Orchestration layer between the MIR interpreter and external data sources. +//! +//! The interpreter executes HashQL programs over MIR bodies but cannot satisfy +//! data access on its own. When execution reaches a [`GraphRead`] terminator, +//! the interpreter yields a [`Suspension`] describing what data is needed and +//! where to resume. The orchestrator takes over: it looks up the pre-compiled +//! SQL query, encodes parameters, sends the query to PostgreSQL, hydrates each +//! result row into a typed [`Value`], runs any client-side filter chains, and +//! packages the output into a [`Continuation`] that the interpreter can apply +//! to resume execution. +//! +//! Key types: +//! +//! - [`Orchestrator`]: top-level driver that owns the database client and query registry. Provides +//! [`run_in`] for full query execution and [`fulfill_in`] for resolving a single suspension. +//! - [`Indexed`]: positional wrapper that carries a column's index alongside its descriptor through +//! the hydration pipeline, used for error reporting. +//! +//! Submodules: +//! +//! - `codec`: JSON codec between interpreter [`Value`]s and the PostgreSQL wire format. The +//! `decode` side deserializes result columns into typed values guided by the HashQL type system; +//! the `encode` side serializes runtime values and query parameters for transmission to +//! PostgreSQL. +//! - `partial`: three-state hydration tracking (Skipped, Null, Value) that assembles flat result +//! columns into nested vertex value trees. Each `Partial*` struct mirrors a level of the vertex +//! type hierarchy. +//! - `postgres`: continuation state for multi-island execution. When a compiled query returns +//! continuation columns (target block, locals, serialized values), this module hydrates and +//! validates them, then flushes the decoded state into the interpreter's callstack. +//! - `request`: per-suspension-type handlers (currently [`GraphRead`]). +//! - `tail`: result accumulation strategies (currently collection into a list). +//! - `error`: error types for all failure modes in the bridge. All variants use `Severity::Bug` +//! because the user wrote HashQL, not SQL: if the bridge fails, the compiler or runtime produced +//! something invalid. +//! +//! [`GraphRead`]: hashql_mir::body::terminator::GraphRead +//! [`Suspension`]: hashql_mir::interpret::suspension::Suspension +//! [`Value`]: hashql_mir::interpret::value::Value +//! [`Continuation`]: hashql_mir::interpret::suspension::Continuation +//! [`run_in`]: Orchestrator::run_in +//! [`fulfill_in`]: Orchestrator::fulfill_in + +use alloc::alloc::Global; +use core::{alloc::Allocator, ops::Deref}; + +use hashql_mir::{ + def::DefId, + interpret::{ + CallStack, Inputs, Runtime, RuntimeConfig, RuntimeError, + error::InterpretDiagnostic, + suspension::{Continuation, Suspension}, + value::Value, + }, +}; +use tokio_postgres::Client; + +pub use self::events::{AppendEventLog, Event, EventLog}; +use self::{error::BridgeError, request::GraphReadOrchestrator}; +use crate::{context::EvalContext, postgres::PreparedQueries}; + +pub mod codec; +pub(crate) mod error; +mod events; +mod partial; +mod postgres; +mod request; +mod tail; + +/// A value paired with its positional index. +/// +/// Used throughout the hydration pipeline to carry a column's index alongside +/// its [`ColumnDescriptor`] so that error diagnostics can report both *which* +/// column failed and *what* it represents. +/// +/// Dereferences to the inner value, so callers can access the descriptor +/// transparently. +/// +/// [`ColumnDescriptor`]: crate::postgres::ColumnDescriptor +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Indexed { + pub index: usize, + value: T, +} + +impl Indexed { + pub(crate) const fn new(index: usize, value: T) -> Self { + Self { index, value } + } +} + +impl Deref for Indexed { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +/// Top-level driver that bridges the MIR interpreter with PostgreSQL. +/// +/// Owns a database [`Client`], a reference to the compiled query registry, and +/// the evaluation context (type environment, body definitions, execution +/// analysis results). The type parameter `C` is reserved for future +/// configuration; `A` is the allocator used by the query registry; `E` is +/// the [`EventLog`] sink for execution tracing. +/// +/// By default `E` is `()`, which compiles all event logging to no-ops. Use +/// [`with_event_log`](Self::with_event_log) to attach a collector such as +/// [`AppendEventLog`] for test assertions or debugging. +/// +/// Use [`run_in`](Self::run_in) to execute a complete query from scratch, or +/// [`fulfill_in`](Self::fulfill_in) / [`fulfill`](Self::fulfill) to resolve an +/// individual [`Suspension`] when driving the interpreter manually. +/// +/// [`Suspension`]: hashql_mir::interpret::suspension::Suspension +pub struct Orchestrator<'env, 'ctx, 'heap, C, E, A: Allocator> { + client: C, + queries: &'env PreparedQueries<'heap, A>, + context: &'env EvalContext<'ctx, 'heap, A>, + /// Event sink for execution tracing. See [`EventLog`]. + pub event_log: E, +} + +impl<'env, 'ctx, 'heap, C, A: Allocator> Orchestrator<'env, 'ctx, 'heap, C, (), A> { + pub const fn new( + client: C, + queries: &'env PreparedQueries<'heap, A>, + context: &'env EvalContext<'ctx, 'heap, A>, + ) -> Self { + Self { + client, + queries, + context, + event_log: (), + } + } +} + +impl<'env, 'ctx, 'heap, C, E, A: Allocator> Orchestrator<'env, 'ctx, 'heap, C, E, A> { + /// Replaces the event log, returning a new orchestrator with the given + /// sink. + pub fn with_event_log(self, event_log: E2) -> Orchestrator<'env, 'ctx, 'heap, C, E2, A> { + Orchestrator { + client: self.client, + queries: self.queries, + context: self.context, + event_log, + } + } +} + +#[expect(clippy::future_not_send)] +impl<'ctx, 'heap, C, E: EventLog, A: Allocator> Orchestrator<'_, 'ctx, 'heap, C, E, A> { + /// Executes a complete query, resolving suspensions in a loop until the + /// interpreter returns a final [`Value`]. + /// + /// Creates a fresh [`Runtime`] and [`CallStack`], then alternates between + /// running the interpreter and fulfilling suspensions until the program + /// either returns or fails. On failure, the callstack is unwound to + /// produce span information for the diagnostic. + /// + /// `L` is the allocator for runtime values and intermediate results. + /// + /// # Errors + /// + /// Returns an [`InterpretDiagnostic`] if the interpreter fails or any + /// suspension cannot be fulfilled (database errors, decoding failures, + /// filter evaluation failures). + /// + /// [`Value`]: hashql_mir::interpret::value::Value + pub async fn run_in( + &self, + inputs: &Inputs<'heap, L>, + + body: DefId, + args: impl IntoIterator, IntoIter: ExactSizeIterator>, + + alloc: L, + ) -> Result, InterpretDiagnostic> + where + C: AsRef, + { + let mut runtime = Runtime::new_in( + RuntimeConfig::default(), + self.context.bodies, + inputs, + alloc.clone(), + ); + runtime.reset(); + + let mut callstack = CallStack::new(&runtime, body, args); + + let Err(error) = try { + loop { + let next = runtime.run_until_suspension(&mut callstack)?; + match next { + hashql_mir::interpret::Yield::Return(value) => { + return Ok(value); + } + hashql_mir::interpret::Yield::Suspension(suspension) => { + let continuation = self + .fulfill_in(inputs, &callstack, suspension, alloc.clone()) + .await?; + + continuation.apply(&mut callstack)?; + } + } + } + }; + + Err( + error.into_diagnostic(callstack.unwind().map(|(_, span)| span), |suspension| { + let span = callstack + .unwind() + .next() + .map_or(self.context.bodies[body].span, |(_, span)| span); + + suspension.into_diagnostic(span, self.context.env) + }), + ) + } + + /// Convenience wrapper around [`run_in`](Self::run_in) that uses the + /// [`Global`] allocator. + /// + /// # Errors + /// + /// Returns an [`InterpretDiagnostic`] on failure. See + /// [`run_in`](Self::run_in). + pub async fn run( + &self, + inputs: &Inputs<'heap, Global>, + body: DefId, + args: impl IntoIterator, IntoIter: ExactSizeIterator>, + ) -> Result, InterpretDiagnostic> + where + C: AsRef, + { + self.run_in(inputs, body, args, Global).await + } + + /// Resolves a single [`Suspension`] by dispatching to the appropriate + /// request handler. + /// + /// Currently only [`GraphRead`] suspensions are supported. Returns a + /// [`Continuation`] that the caller + /// must [`apply`] to the callstack to resume interpretation. + /// + /// # Errors + /// + /// Returns a [`RuntimeError`] if query execution, row hydration, or + /// filter evaluation fails. + /// + /// [`Suspension`]: hashql_mir::interpret::suspension::Suspension + /// [`GraphRead`]: hashql_mir::body::terminator::GraphRead + /// [`Continuation`]: hashql_mir::interpret::suspension::Continuation + /// [`apply`]: hashql_mir::interpret::suspension::Continuation::apply + pub async fn fulfill_in( + &self, + inputs: &Inputs<'heap, L>, + callstack: &CallStack<'ctx, 'heap, L>, + suspension: Suspension<'ctx, 'heap>, + alloc: L, + ) -> Result, RuntimeError<'heap, BridgeError<'heap>, L>> + where + C: AsRef, + { + match suspension { + Suspension::GraphRead(suspension) => { + GraphReadOrchestrator::new(self) + .fulfill_in(inputs, callstack, suspension, alloc) + .await + } + } + } + + /// Convenience wrapper around [`fulfill_in`](Self::fulfill_in) that uses + /// the [`Global`] allocator. + /// + /// # Errors + /// + /// Returns a [`RuntimeError`] if query execution, row hydration, or + /// filter evaluation fails. See [`fulfill_in`](Self::fulfill_in). + pub async fn fulfill( + &self, + inputs: &Inputs<'heap, Global>, + callstack: &CallStack<'ctx, 'heap, Global>, + suspension: Suspension<'ctx, 'heap>, + ) -> Result, RuntimeError<'heap, BridgeError<'heap>, Global>> + where + C: AsRef, + { + self.fulfill_in(inputs, callstack, suspension, Global).await + } +} diff --git a/libs/@local/hashql/eval/src/orchestrator/partial.rs b/libs/@local/hashql/eval/src/orchestrator/partial.rs new file mode 100644 index 00000000000..ef81274e199 --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/partial.rs @@ -0,0 +1,1039 @@ +//! Partial vertex representation for row hydration. +//! +//! When the bridge receives a row from PostgreSQL, each column corresponds to +//! a leaf [`TraversalPath`] in the provides set. The columns are flat, one per +//! requested storage location, but the interpreter expects a nested [`Value`] +//! tree with intermediate structs, opaque wrappers, and correct `Option` +//! representation. +//! +//! This module provides [`Hydrated`], a three-state enum that tracks whether +//! a field was requested by the query and, if so, whether the database +//! returned a value or `NULL`. The [`Required`] and [`Optional`] aliases +//! restrict the state space based on the schema: non-nullable fields cannot +//! be [`Null`](Hydrated::Null), enforced at the type level via the +//! uninhabited type [`!`]. +//! +//! The `Partial*` structs mirror the vertex type hierarchy. Each leaf field +//! holds a [`Hydrated`] wrapping a [`Value`]; intermediate structs group +//! fields by their position in the type tree. Conversion from a partial +//! struct to a [`Value`] is a separate step that walks the partial tree, +//! wraps intermediate levels in their opaque constructors, and collapses +//! `Option` boundaries. +//! +//! [`TraversalPath`]: hashql_mir::pass::execution::traversal::TraversalPath +//! [`Value`]: hashql_mir::interpret::value::Value + +use alloc::rc::Rc; +use core::alloc::Allocator; + +use hashql_core::{ + symbol::{Symbol, sym}, + r#type::{TypeId, environment::Environment}, +}; +use hashql_mir::{ + intern::Interner, + interpret::value::{Int, Num, Opaque, StructBuilder, Value}, + pass::execution::{ + VertexType, + traversal::{EntityPath, TraversalPath}, + }, +}; +use tokio_postgres::Row; +use uuid::Uuid; + +use super::{ + Indexed, + codec::{JsonValueRef, decode::Decoder}, + error::BridgeError, +}; +use crate::postgres::ColumnDescriptor; + +macro_rules! hydrate { + ($this:ident -> $entry:ident $(-> $field:ident)+ = $value:expr) => { + $this .$entry $(.ensure().$field)+ .set($value) + }; +} + +/// Per-field hydration state for partial entity assembly. +/// +/// Each field in the partial entity representation has one of three states: +/// +/// - **Skipped**: the query's provides set did not include this field. The field will be omitted +/// from the assembled [`Value`] struct entirely. +/// - **Null**: the query requested this field, but the database returned `NULL`. This only occurs +/// for schema-optional fields (e.g. `link_data` on non-link entities, where a `LEFT JOIN` +/// produces all `NULL`s). The type parameter `A` controls whether this variant is constructible. +/// - **Value**: the query requested this field and data was returned. +/// +/// Use the [`Required`] and [`Optional`] aliases rather than specifying `A` directly. +/// +/// [`Value`]: hashql_mir::interpret::value::Value +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub(crate) enum Hydrated { + /// Not in the provides set. The query did not request this field. + #[default] + Skipped, + /// Requested, but the database returned `NULL`. + /// + /// Only constructible when `A = ()` ([`Optional`] fields). For [`Required`] + /// fields (`A = !`), this variant is uninhabited. + Null(N), + /// Requested and present. + Value(T), +} + +impl Hydrated { + pub(crate) fn map(self, func: impl FnOnce(T) -> U) -> Hydrated { + match self { + Self::Skipped => Hydrated::Skipped, + Self::Null(marker) => Hydrated::Null(marker), + Self::Value(value) => Hydrated::Value(func(value)), + } + } +} + +impl Hydrated { + /// Sets this required field to [`Value`](Self::Value). + /// + /// # Panics (debug only) + /// + /// Debug-panics if the field is already populated. + pub(crate) fn set(&mut self, value: T) { + debug_assert!( + matches!(self, Self::Skipped), + "field already populated: duplicate column in row hydration" + ); + + *self = Self::Value(value); + } +} + +impl Hydrated { + /// Sets this optional field from a nullable column value. + /// + /// [`Some`] produces [`Value`](Self::Value), [`None`] produces + /// [`Null`](Self::Null). + /// + /// # Panics (debug only) + /// + /// Debug-panics if the field is already populated. + pub(crate) fn set(&mut self, value: Option) { + debug_assert!( + matches!(self, Self::Skipped), + "field already populated: duplicate column in row hydration" + ); + + match value { + Some(value) => *self = Self::Value(value), + None => *self = Self::Null(()), + } + } + + pub(crate) fn null(&mut self) { + debug_assert!( + matches!(self, Self::Skipped | Self::Null(())), + "field already populated with a value: cannot null" + ); + + *self = Self::Null(()); + } + + pub(crate) fn try_null(&mut self) { + if matches!(self, Self::Value(_)) { + *self = Self::Null(()); + } + } + + pub(crate) fn filter(self, func: impl FnOnce(&T) -> bool) -> Self { + match self { + Self::Skipped => Self::Skipped, + Self::Null(()) => Self::Null(()), + Self::Value(value) => { + if func(&value) { + Self::Value(value) + } else { + Self::Null(()) + } + } + } + } +} + +impl Hydrated { + /// Ensures this field contains a value, initializing it with [`Default::default`] + /// if it was [`Skipped`](Self::Skipped) or [`Null`](Self::Null). + /// + /// Returns a mutable reference to the inner value for further drilling. + /// + /// This is the primary mechanism for populating nested partial structs + /// from flat column values: each intermediate level is initialized on + /// first access, then the caller continues into the next level. + pub(crate) fn ensure(&mut self) -> &mut T { + if !matches!(self, Self::Value(_)) { + *self = Self::Value(T::default()); + } + + match self { + Self::Value(value) => value, + Self::Skipped | Self::Null(_) => unreachable!(), + } + } +} + +impl<'heap, A: Allocator> Hydrated, !> { + pub(crate) fn finish_in( + self, + builder: &mut StructBuilder<'heap, A, N>, + field: Symbol<'heap>, + ) { + let value = match self { + Self::Skipped => return, + Self::Value(value) => value, + }; + + builder.push(field, value); + } +} + +impl<'heap, A: Allocator> Hydrated, ()> { + pub(crate) fn finish_in( + self, + builder: &mut StructBuilder<'heap, A, N>, + field: Symbol<'heap>, + alloc: A, + ) { + let value = match self { + Self::Skipped => return, + Self::Null(()) => { + Value::Opaque(Opaque::new(sym::path::None, Rc::new_in(Value::Unit, alloc))) + } + Self::Value(value) => { + Value::Opaque(Opaque::new(sym::path::Some, Rc::new_in(value, alloc))) + } + }; + + builder.push(field, value); + } +} + +/// Hydration state for a non-nullable schema field. +/// +/// The [`Null`](Hydrated::Null) variant is uninhabited: a required field is either +/// [`Skipped`](Hydrated::Skipped) or has a [`Value`](Hydrated::Value). +pub(crate) type Required = Hydrated; + +/// Hydration state for a nullable schema field. +/// +/// All three states are inhabited: [`Skipped`](Hydrated::Skipped), +/// [`Null`](Hydrated::Null), or [`Value`](Hydrated::Value). +/// [`Null`](Hydrated::Null) represents a schema-level absence (e.g. `link_data` +/// on a non-link entity), not a missing column. +pub(crate) type Optional = Hydrated; + +/// Partial representation of `EntityEncodings`. +pub(crate) struct PartialEncodings<'heap, A: Allocator> { + pub vectors: Required>, +} + +impl<'heap, A: Allocator> PartialEncodings<'heap, A> { + pub(crate) fn finish_in(self, interner: &Interner<'heap>, alloc: A) -> Value<'heap, A> + where + A: Clone, + { + let mut builder: StructBuilder<'heap, A, 1> = StructBuilder::new(); + self.vectors.finish_in(&mut builder, sym::vectors); + + let value = Value::Struct(builder.finish(interner, alloc.clone())); + Value::Opaque(Opaque::new( + sym::path::EntityEncodings, + Rc::new_in(value, alloc), + )) + } +} + +impl Default for PartialEncodings<'_, A> { + fn default() -> Self { + Self { + vectors: Required::Skipped, + } + } +} + +/// Partial identity of a linked entity (left or right target of a link). +/// +/// Unlike [`PartialEntityId`], this only has `web_id` and `entity_uuid`: +/// link targets are not addressable by `draft_id` through +/// [`EntityPath`]. +pub(crate) struct PartialLinkEntityId<'heap, A: Allocator> { + pub web_id: Required>, + pub entity_uuid: Required>, +} + +impl<'heap, A: Allocator> PartialLinkEntityId<'heap, A> { + pub(crate) fn finish_in(self, interner: &Interner<'heap>, alloc: A) -> Value<'heap, A> + where + A: Clone, + { + let mut builder: StructBuilder<'heap, A, 3> = StructBuilder::new(); + self.web_id.finish_in(&mut builder, sym::web_id); + self.entity_uuid.finish_in(&mut builder, sym::entity_uuid); + + builder.push( + sym::draft_id, + Value::Opaque(Opaque::new( + sym::path::None, + Rc::new_in(Value::Unit, alloc.clone()), + )), + ); + + let inner = Value::Struct(builder.finish(interner, alloc.clone())); + Value::Opaque(Opaque::new(sym::path::EntityId, Rc::new_in(inner, alloc))) + } +} + +impl Default for PartialLinkEntityId<'_, A> { + fn default() -> Self { + Self { + web_id: Required::Skipped, + entity_uuid: Required::Skipped, + } + } +} + +/// Partial representation of `EntityProvenance`. +pub(crate) struct PartialProvenance<'heap, A: Allocator> { + pub inferred: Required>, + pub edition: Required>, +} + +impl<'heap, A: Allocator> PartialProvenance<'heap, A> { + pub(crate) fn finish_in(self, interner: &Interner<'heap>, alloc: A) -> Value<'heap, A> + where + A: Clone, + { + let mut builder: StructBuilder<'heap, A, 2> = StructBuilder::new(); + self.inferred.finish_in(&mut builder, sym::inferred); + self.edition.finish_in(&mut builder, sym::edition); + + let value = Value::Struct(builder.finish(interner, alloc.clone())); + + Value::Opaque(Opaque::new( + sym::path::EntityProvenance, + Rc::new_in(value, alloc), + )) + } +} + +impl Default for PartialProvenance<'_, A> { + fn default() -> Self { + Self { + inferred: Required::Skipped, + edition: Required::Skipped, + } + } +} + +/// Partial representation of `TemporalMetadata`. +pub(crate) struct PartialTemporalVersioning<'heap, A: Allocator> { + pub decision_time: Required>, + pub transaction_time: Required>, +} + +impl<'heap, A: Allocator> PartialTemporalVersioning<'heap, A> { + pub(crate) fn finish_in(self, interner: &Interner<'heap>, alloc: A) -> Value<'heap, A> + where + A: Clone, + { + let mut builder: StructBuilder<'heap, A, 2> = StructBuilder::new(); + self.decision_time + .finish_in(&mut builder, sym::decision_time); + self.transaction_time + .finish_in(&mut builder, sym::transaction_time); + + let value = Value::Struct(builder.finish(interner, alloc.clone())); + Value::Opaque(Opaque::new( + sym::path::TemporalMetadata, + Rc::new_in(value, alloc), + )) + } +} + +impl Default for PartialTemporalVersioning<'_, A> { + fn default() -> Self { + Self { + decision_time: Required::Skipped, + transaction_time: Required::Skipped, + } + } +} + +/// Partial representation of `EntityId` (the entity's own identity). +/// +/// Schema field `draft_id` is `Option`, the others are required. +/// +/// This is distinct from [`PartialLinkEntityId`], which represents the +/// identity of a *linked* entity and does not include `draft_id`. +pub(crate) struct PartialEntityId<'heap, A: Allocator> { + pub web_id: Required>, + pub entity_uuid: Required>, + pub draft_id: Optional>, +} + +impl<'heap, A: Allocator> PartialEntityId<'heap, A> { + pub(crate) fn finish_in(self, interner: &Interner<'heap>, alloc: A) -> Value<'heap, A> + where + A: Clone, + { + let mut builder: StructBuilder<'heap, A, 3> = StructBuilder::new(); + self.web_id.finish_in(&mut builder, sym::web_id); + self.entity_uuid.finish_in(&mut builder, sym::entity_uuid); + self.draft_id + .finish_in(&mut builder, sym::draft_id, alloc.clone()); + + let value = Value::Struct(builder.finish(interner, alloc.clone())); + Value::Opaque(Opaque::new(sym::path::EntityId, Rc::new_in(value, alloc))) + } +} + +impl Default for PartialEntityId<'_, A> { + fn default() -> Self { + Self { + web_id: Required::Skipped, + entity_uuid: Required::Skipped, + draft_id: Optional::Skipped, + } + } +} + +/// Partial representation of `RecordId`. +/// +/// Contains `entity_id` (composite of web, uuid, draft) and `edition_id`. +pub(crate) struct PartialRecordId<'heap, A: Allocator> { + pub entity_id: Required>, + pub edition_id: Required>, +} + +impl<'heap, A: Allocator> PartialRecordId<'heap, A> { + pub(crate) fn finish_in(self, interner: &Interner<'heap>, alloc: A) -> Value<'heap, A> + where + A: Clone, + { + let mut builder: StructBuilder<'heap, A, 2> = StructBuilder::new(); + + self.entity_id + .map(|value| value.finish_in(interner, alloc.clone())) + .finish_in(&mut builder, sym::entity_id); + self.edition_id.finish_in(&mut builder, sym::edition_id); + + let value = Value::Struct(builder.finish(interner, alloc.clone())); + Value::Opaque(Opaque::new(sym::path::RecordId, Rc::new_in(value, alloc))) + } +} + +impl Default for PartialRecordId<'_, A> { + fn default() -> Self { + Self { + entity_id: Required::Skipped, + edition_id: Required::Skipped, + } + } +} + +/// Partial representation of `LinkData`. +/// +/// Schema fields `left_entity_confidence` and `right_entity_confidence` are +/// `Option`, all others are required. +/// +/// The entity ID fields use [`PartialLinkEntityId`] (web + uuid only), +/// not [`PartialEntityId`] (which includes `draft_id`). +pub(crate) struct PartialLinkData<'heap, A: Allocator> { + pub left_entity_id: Required>, + pub right_entity_id: Required>, + pub left_entity_confidence: Optional>, + pub left_entity_provenance: Required>, + pub right_entity_confidence: Optional>, + pub right_entity_provenance: Required>, +} + +impl<'heap, A: Allocator> PartialLinkData<'heap, A> { + pub(crate) fn finish_in(self, interner: &Interner<'heap>, alloc: A) -> Value<'heap, A> + where + A: Clone, + { + let mut builder: StructBuilder<'heap, A, 6> = StructBuilder::new(); + + self.left_entity_id + .map(|value| value.finish_in(interner, alloc.clone())) + .finish_in(&mut builder, sym::left_entity_id); + self.right_entity_id + .map(|value| value.finish_in(interner, alloc.clone())) + .finish_in(&mut builder, sym::right_entity_id); + self.left_entity_confidence.finish_in( + &mut builder, + sym::left_entity_confidence, + alloc.clone(), + ); + self.left_entity_provenance + .finish_in(&mut builder, sym::left_entity_provenance); + self.right_entity_confidence.finish_in( + &mut builder, + sym::right_entity_confidence, + alloc.clone(), + ); + self.right_entity_provenance + .finish_in(&mut builder, sym::right_entity_provenance); + + let value = Value::Struct(builder.finish(interner, alloc.clone())); + Value::Opaque(Opaque::new(sym::path::LinkData, Rc::new_in(value, alloc))) + } + + const fn has_value(&self) -> bool { + let Self { + left_entity_id, + right_entity_id, + left_entity_confidence, + left_entity_provenance, + right_entity_confidence, + right_entity_provenance, + } = self; + + matches!(left_entity_id, Hydrated::Value(_)) + || matches!(right_entity_id, Hydrated::Value(_)) + || matches!(left_entity_confidence, Hydrated::Value(_)) + || matches!(left_entity_provenance, Hydrated::Value(_)) + || matches!(right_entity_confidence, Hydrated::Value(_)) + || matches!(right_entity_provenance, Hydrated::Value(_)) + } +} + +impl Default for PartialLinkData<'_, A> { + fn default() -> Self { + Self { + left_entity_id: Required::Skipped, + right_entity_id: Required::Skipped, + left_entity_confidence: Optional::Skipped, + left_entity_provenance: Required::Skipped, + right_entity_confidence: Optional::Skipped, + right_entity_provenance: Required::Skipped, + } + } +} + +/// Partial representation of `EntityMetadata`. +/// +/// Schema field `confidence` is `Option`, all others are required. +/// The `property_metadata` field corresponds to the `properties` field in the +/// schema type ([`EntityPath::PropertyMetadata`]), renamed here to avoid +/// confusion with the entity's top-level `properties`. +/// +/// [`EntityPath::PropertyMetadata`]: hashql_mir::pass::execution::traversal::EntityPath::PropertyMetadata +pub(crate) struct PartialMetadata<'heap, A: Allocator> { + pub record_id: Required>, + pub temporal_versioning: Required>, + pub entity_type_ids: Required>, + pub archived: Required>, + pub provenance: Required>, + pub confidence: Optional>, + pub property_metadata: Required>, +} + +impl<'heap, A: Allocator> PartialMetadata<'heap, A> { + pub(crate) fn finish_in(self, interner: &Interner<'heap>, alloc: A) -> Value<'heap, A> + where + A: Clone, + { + let mut builder: StructBuilder<'heap, A, 7> = StructBuilder::new(); + + self.record_id + .map(|partial| partial.finish_in(interner, alloc.clone())) + .finish_in(&mut builder, sym::record_id); + self.temporal_versioning + .map(|partial| partial.finish_in(interner, alloc.clone())) + .finish_in(&mut builder, sym::temporal_versioning); + self.entity_type_ids + .finish_in(&mut builder, sym::entity_type_ids); + self.archived.finish_in(&mut builder, sym::archived); + self.provenance + .map(|partial| partial.finish_in(interner, alloc.clone())) + .finish_in(&mut builder, sym::provenance); + self.confidence + .finish_in(&mut builder, sym::confidence, alloc.clone()); + self.property_metadata + .finish_in(&mut builder, sym::property_metadata); + + let value = Value::Struct(builder.finish(interner, alloc.clone())); + Value::Opaque(Opaque::new( + sym::path::EntityMetadata, + Rc::new_in(value, alloc), + )) + } +} + +impl Default for PartialMetadata<'_, A> { + fn default() -> Self { + Self { + record_id: Required::Skipped, + temporal_versioning: Required::Skipped, + entity_type_ids: Required::Skipped, + archived: Required::Skipped, + provenance: Required::Skipped, + confidence: Optional::Skipped, + property_metadata: Required::Skipped, + } + } +} + +/// Partial representation of `Entity`. +/// +/// Mirrors the top-level entity struct with four fields: +/// - `properties`: the generic `T` parameter, always a leaf [`Value`] +/// - `metadata`: [`EntityMetadata`], a deep nested struct +/// - `link_data`: `Option`, nullable at the schema level +/// - `encodings`: [`EntityEncodings`], currently just `vectors` +/// +/// [`EntityMetadata`]: hashql_core::module::std_lib::graph::types::knowledge::entity::types::entity_metadata +/// [`EntityEncodings`]: hashql_core::module::std_lib::graph::types::knowledge::entity::types::entity_encodings +pub(crate) struct PartialEntity<'heap, A: Allocator> { + pub properties: Required>, + pub metadata: Required>, + pub link_data: Optional>, + pub encodings: Required>, +} + +impl<'heap, A: Allocator> PartialEntity<'heap, A> { + pub(crate) fn finish_in(self, interner: &Interner<'heap>, alloc: A) -> Value<'heap, A> + where + A: Clone, + { + let mut builder: StructBuilder<'heap, A, 4> = StructBuilder::new(); + self.properties.finish_in(&mut builder, sym::properties); + self.metadata + .map(|partial| partial.finish_in(interner, alloc.clone())) + .finish_in(&mut builder, sym::metadata); + self.link_data + .filter(PartialLinkData::has_value) + .map(|partial| partial.finish_in(interner, alloc.clone())) + .finish_in(&mut builder, sym::link_data, alloc.clone()); + self.encodings + .map(|partial| partial.finish_in(interner, alloc.clone())) + .finish_in(&mut builder, sym::encodings); + + let value = Value::Struct(builder.finish(interner, alloc.clone())); + Value::Opaque(Opaque::new(sym::path::Entity, Rc::new_in(value, alloc))) + } + + #[expect(clippy::too_many_lines)] + fn hydrate_from_postgres( + &mut self, + env: &Environment<'heap>, + decoder: &Decoder<'_, 'heap, A>, + path: EntityPath, + r#type: TypeId, + column: Indexed, + row: &Row, + ) -> Result<(), BridgeError<'heap>> + where + A: Clone, + { + let row_hydration_error = |source| BridgeError::RowHydration { column, source }; + + match path { + EntityPath::Properties => { + let value: serde_json::Value = + row.try_get(column.index).map_err(row_hydration_error)?; + let value = decoder.try_decode(r#type, (&value).into(), column)?; + self.properties.set(value); + } + EntityPath::Vectors => unreachable!( + "entity vectors should never reach postgres compilation; the placement pass \ + should have rejected this" + ), + EntityPath::RecordId => { + let value: serde_json::Value = + row.try_get(column.index).map_err(row_hydration_error)?; + + let entity_id = &value["entity_id"]; + let edition_id = &value["edition_id"]; + + self.hydrate_entity_id(env, decoder, column, entity_id)?; + self.hydrate_edition_id(env, decoder, column, edition_id)?; + } + EntityPath::EntityId => { + let value: serde_json::Value = + row.try_get(column.index).map_err(row_hydration_error)?; + + self.hydrate_entity_id(env, decoder, column, &value)?; + } + EntityPath::WebId => { + let value: Uuid = row.try_get(column.index).map_err(row_hydration_error)?; + self.hydrate_web_id( + env, + decoder, + column, + JsonValueRef::String(&value.hyphenated().to_string()), + )?; + } + EntityPath::EntityUuid => { + let value: Uuid = row.try_get(column.index).map_err(row_hydration_error)?; + self.hydrate_entity_uuid( + env, + decoder, + column, + JsonValueRef::String(&value.hyphenated().to_string()), + )?; + } + EntityPath::DraftId => { + let value: Option = row.try_get(column.index).map_err(row_hydration_error)?; + let value = value.map(|uuid| uuid.hyphenated().to_string()); + + self.hydrate_draft_id( + env, + decoder, + column, + value.as_deref().map(JsonValueRef::String), + )?; + } + EntityPath::EditionId => { + let value: Uuid = row.try_get(column.index).map_err(row_hydration_error)?; + self.hydrate_edition_id( + env, + decoder, + column, + JsonValueRef::String(&value.hyphenated().to_string()), + )?; + } + EntityPath::TemporalVersioning => { + let value: serde_json::Value = + row.try_get(column.index).map_err(row_hydration_error)?; + let transaction_time = &value["transaction_time"]; + let decision_time = &value["decision_time"]; + + self.hydrate_decision_time(env, decoder, column, decision_time)?; + self.hydrate_transaction_time(env, decoder, column, transaction_time)?; + } + EntityPath::DecisionTime => { + let value: serde_json::Value = + row.try_get(column.index).map_err(row_hydration_error)?; + self.hydrate_decision_time(env, decoder, column, &value)?; + } + EntityPath::TransactionTime => { + let value: serde_json::Value = + row.try_get(column.index).map_err(row_hydration_error)?; + self.hydrate_transaction_time(env, decoder, column, &value)?; + } + EntityPath::EntityTypeIds => { + let value: serde_json::Value = + row.try_get(column.index).map_err(row_hydration_error)?; + let value = decoder.try_decode(r#type, (&value).into(), column)?; + hydrate!(self->metadata->entity_type_ids = value); + } + EntityPath::Archived => { + let value: bool = row.try_get(column.index).map_err(row_hydration_error)?; + hydrate!(self->metadata->archived = Value::Integer(Int::from(value))); + } + EntityPath::Confidence => { + let value: Option = row.try_get(column.index).map_err(row_hydration_error)?; + hydrate!(self->metadata->confidence = value.map(Num::from).map(Value::Number)); + } + EntityPath::ProvenanceInferred => { + let value: serde_json::Value = + row.try_get(column.index).map_err(row_hydration_error)?; + + let value = decoder.try_decode(r#type, (&value).into(), column)?; + hydrate!(self->metadata->provenance->inferred = value); + } + EntityPath::ProvenanceEdition => { + let value: serde_json::Value = + row.try_get(column.index).map_err(row_hydration_error)?; + + let value = decoder.try_decode(r#type, (&value).into(), column)?; + hydrate!(self->metadata->provenance->edition = value); + } + EntityPath::PropertyMetadata => { + let value: serde_json::Value = + row.try_get(column.index).map_err(row_hydration_error)?; + + let value = decoder.try_decode(r#type, (&value).into(), column)?; + hydrate!(self->metadata->property_metadata = value); + } + EntityPath::LeftEntityWebId => { + let value: Option = row.try_get(column.index).map_err(row_hydration_error)?; + + let Some(value) = value else { + self.link_data.null(); + return Ok(()); + }; + + let value = decoder.try_decode( + r#type, + JsonValueRef::String(&value.hyphenated().to_string()), + column, + )?; + hydrate!(self->link_data->left_entity_id->web_id = value); + } + EntityPath::LeftEntityUuid => { + let value: Option = row.try_get(column.index).map_err(row_hydration_error)?; + + let Some(value) = value else { + self.link_data.null(); + return Ok(()); + }; + + let value = decoder.try_decode( + r#type, + JsonValueRef::String(&value.hyphenated().to_string()), + column, + )?; + hydrate!(self->link_data->left_entity_id->entity_uuid = value); + } + EntityPath::RightEntityWebId => { + let value: Option = row.try_get(column.index).map_err(row_hydration_error)?; + + let Some(value) = value else { + self.link_data.null(); + return Ok(()); + }; + + let value = decoder.try_decode( + r#type, + JsonValueRef::String(&value.hyphenated().to_string()), + column, + )?; + hydrate!(self->link_data->right_entity_id->web_id = value); + } + EntityPath::RightEntityUuid => { + let value: Option = row.try_get(column.index).map_err(row_hydration_error)?; + + let Some(value) = value else { + self.link_data.null(); + return Ok(()); + }; + + let value = decoder.try_decode( + r#type, + JsonValueRef::String(&value.hyphenated().to_string()), + column, + )?; + hydrate!(self->link_data->right_entity_id->entity_uuid = value); + } + EntityPath::LeftEntityConfidence => { + let value: Option = row.try_get(column.index).map_err(row_hydration_error)?; + hydrate!(self->link_data->left_entity_confidence = value.map(Num::from).map(Value::Number)); + } + EntityPath::RightEntityConfidence => { + let value: Option = row.try_get(column.index).map_err(row_hydration_error)?; + hydrate!(self->link_data->right_entity_confidence = value.map(Num::from).map(Value::Number)); + } + EntityPath::LeftEntityProvenance => { + let value: Option = + row.try_get(column.index).map_err(row_hydration_error)?; + + let Some(value) = value else { + self.link_data.try_null(); + return Ok(()); + }; + + let value = decoder.try_decode(r#type, (&value).into(), column)?; + hydrate!(self->link_data->left_entity_provenance = value); + } + EntityPath::RightEntityProvenance => { + let value: Option = + row.try_get(column.index).map_err(row_hydration_error)?; + + let Some(value) = value else { + self.link_data.try_null(); + return Ok(()); + }; + + let value = decoder.try_decode(r#type, (&value).into(), column)?; + hydrate!(self->link_data->right_entity_provenance = value); + } + } + + Ok(()) + } + + fn hydrate_entity_id( + &mut self, + env: &Environment<'heap>, + decoder: &Decoder<'_, 'heap, A>, + column: Indexed, + value: &serde_json::Value, + ) -> Result<(), BridgeError<'heap>> + where + A: Clone, + { + let web_id = &value["web_id"]; + let entity_uuid = &value["entity_uuid"]; + + self.hydrate_web_id(env, decoder, column, web_id)?; + self.hydrate_entity_uuid(env, decoder, column, entity_uuid)?; + self.hydrate_draft_id(env, decoder, column, value.get("draft_id"))?; + + Ok(()) + } + + fn hydrate_decision_time<'value>( + &mut self, + env: &Environment<'heap>, + decoder: &Decoder<'_, 'heap, A>, + column: Indexed, + value: impl Into>, + ) -> Result<(), BridgeError<'heap>> + where + A: Clone, + { + let value = decoder.try_decode( + EntityPath::DecisionTime.expect_type(env), + value.into(), + column, + )?; + hydrate!(self->metadata->temporal_versioning->decision_time = value); + + Ok(()) + } + + fn hydrate_transaction_time<'value>( + &mut self, + env: &Environment<'heap>, + decoder: &Decoder<'_, 'heap, A>, + column: Indexed, + value: impl Into>, + ) -> Result<(), BridgeError<'heap>> + where + A: Clone, + { + let value = decoder.try_decode( + EntityPath::TransactionTime.expect_type(env), + value.into(), + column, + )?; + hydrate!(self->metadata->temporal_versioning->transaction_time = value); + + Ok(()) + } + + fn hydrate_web_id<'value>( + &mut self, + env: &Environment<'heap>, + decoder: &Decoder<'_, 'heap, A>, + column: Indexed, + value: impl Into>, + ) -> Result<(), BridgeError<'heap>> + where + A: Clone, + { + let value = decoder.try_decode(EntityPath::WebId.expect_type(env), value.into(), column)?; + hydrate!(self->metadata->record_id->entity_id->web_id = value); + + Ok(()) + } + + fn hydrate_entity_uuid<'value>( + &mut self, + env: &Environment<'heap>, + decoder: &Decoder<'_, 'heap, A>, + column: Indexed, + value: impl Into>, + ) -> Result<(), BridgeError<'heap>> + where + A: Clone, + { + let value = decoder.try_decode( + EntityPath::EntityUuid.expect_type(env), + value.into(), + column, + )?; + hydrate!(self->metadata->record_id->entity_id->entity_uuid = value); + + Ok(()) + } + + fn hydrate_draft_id<'value>( + &mut self, + env: &Environment<'heap>, + decoder: &Decoder<'_, 'heap, A>, + column: Indexed, + value: Option>>, + ) -> Result<(), BridgeError<'heap>> + where + A: Clone, + { + let value = value + .map(Into::into) + .filter(|value| !matches!(value, JsonValueRef::Null)) + .map(|value| decoder.try_decode(EntityPath::DraftId.expect_type(env), value, column)) + .transpose()?; + hydrate!(self->metadata->record_id->entity_id->draft_id = value); + + Ok(()) + } + + fn hydrate_edition_id<'value>( + &mut self, + env: &Environment<'heap>, + decoder: &Decoder<'_, 'heap, A>, + column: Indexed, + value: impl Into>, + ) -> Result<(), BridgeError<'heap>> + where + A: Clone, + { + let value = + decoder.try_decode(EntityPath::EditionId.expect_type(env), value.into(), column)?; + hydrate!(self->metadata->record_id->edition_id = value); + + Ok(()) + } +} + +impl Default for PartialEntity<'_, A> { + fn default() -> Self { + Self { + properties: Required::Skipped, + metadata: Required::Skipped, + link_data: Optional::Skipped, + encodings: Required::Skipped, + } + } +} + +pub(crate) enum Partial<'heap, A: Allocator> { + Entity(PartialEntity<'heap, A>), +} + +impl<'heap, A: Allocator> Partial<'heap, A> { + pub(crate) fn new(vertex_type: VertexType) -> Self { + match vertex_type { + VertexType::Entity => Self::Entity(PartialEntity::default()), + } + } + + pub(crate) fn hydrate_from_postgres( + &mut self, + env: &Environment<'heap>, + decoder: &Decoder<'_, 'heap, A>, + path: TraversalPath, + r#type: TypeId, + column: Indexed, + row: &Row, + ) -> Result<(), BridgeError<'heap>> + where + A: Clone, + { + match (self, path) { + (Self::Entity(entity), TraversalPath::Entity(entity_path)) => { + entity.hydrate_from_postgres(env, decoder, entity_path, r#type, column, row) + } + } + } + + pub(crate) fn finish_in(self, interner: &Interner<'heap>, alloc: A) -> Value<'heap, A> + where + A: Clone, + { + match self { + Self::Entity(entity) => entity.finish_in(interner, alloc), + } + } +} diff --git a/libs/@local/hashql/eval/src/orchestrator/postgres.rs b/libs/@local/hashql/eval/src/orchestrator/postgres.rs new file mode 100644 index 00000000000..15c3437b59a --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/postgres.rs @@ -0,0 +1,249 @@ +//! Continuation state for resuming interpreter execution after a database +//! round-trip. +//! +//! When a compiled query returns continuation columns (target block, locals, +//! values), they arrive as flat nullable fields in the result row. This module +//! provides [`PartialPostgresState`] for accumulating those fields during +//! hydration, and [`PostgresState`] for the validated, decoded form that can +//! be flushed into a [`CallStack`] to resume interpretation at the correct +//! basic block with the correct local variable bindings. +//! +//! [`CallStack`]: hashql_mir::interpret::CallStack + +use core::alloc::Allocator; + +use hashql_mir::{ + body::{Body, basic_block::BasicBlockId, local::Local}, + def::DefId, + interpret::{CallStack, RuntimeError, value::Value}, + pass::execution::IslandId, +}; +use tokio_postgres::Row; + +use super::{Indexed, codec::decode::Decoder, error::BridgeError, partial::Optional}; +use crate::{ + orchestrator::partial::Hydrated, + postgres::{ColumnDescriptor, ContinuationField}, +}; + +/// In-progress continuation state being assembled from result row columns. +/// +/// Each continuation is identified by a `(body, island)` pair. As columns are +/// encountered during hydration, [`hydrate`](Self::hydrate) populates the +/// target block, locals, and values fields. Once all columns for a row have +/// been processed, [`finish_in`](Self::finish_in) validates completeness and +/// decodes the JSON values into typed [`Value`]s, producing a +/// [`PostgresState`] (or `None` if the continuation target was `NULL`, +/// indicating no resumption is needed). +/// +/// [`Value`]: hashql_mir::interpret::value::Value +pub(crate) struct PartialPostgresState { + pub body: DefId, + pub island: IslandId, + + target: Optional, + locals: Optional>, + values: Optional>, +} + +impl PartialPostgresState { + pub(crate) const fn new(body: DefId, island: IslandId) -> Self { + Self { + body, + island, + target: Optional::Skipped, + locals: Optional::Skipped, + values: Optional::Skipped, + } + } + + pub(crate) fn hydrate<'heap>( + &mut self, + column: Indexed, + field: ContinuationField, + row: &Row, + alloc: A, + ) -> Result<(), BridgeError<'heap>> { + match field { + ContinuationField::Block => { + // row is a single (nullable) block id, encoded as an int + let block_id: Option = + row.try_get(column.index) + .map_err(|error| BridgeError::RowHydration { + column, + source: error, + })?; + + match block_id { + Some(block_id) => { + let block_id = u32::try_from(block_id).map_err(|_err| { + BridgeError::InvalidContinuationBlockId { + body: self.body, + block_id, + } + })?; + + self.target = Optional::Value(BasicBlockId::new(block_id)); + } + None => { + self.target = Optional::Null(()); + } + } + } + ContinuationField::Locals => { + let locals: Option> = + row.try_get(column.index) + .map_err(|error| BridgeError::RowHydration { + column, + source: error, + })?; + + match locals { + Some(locals) => { + let mut result = Vec::with_capacity_in(locals.len(), alloc); + for local in locals { + let local = u32::try_from(local).map(Local::new).map_err(|_err| { + BridgeError::InvalidContinuationLocal { + body: self.body, + local, + } + })?; + result.push(local); + } + self.locals = Optional::Value(result); + } + None => { + self.locals = Optional::Null(()); + } + } + } + ContinuationField::Values => { + let values: Option> = + row.try_get(column.index) + .map_err(|error| BridgeError::RowHydration { + column, + source: error, + })?; + + match values { + Some(values) => { + self.values = Optional::Value(values); + } + None => { + self.values = Optional::Null(()); + } + } + } + } + + Ok(()) + } + + pub(crate) fn finish_in<'heap>( + self, + decoder: &Decoder<'_, 'heap, A>, + body: &Body<'heap>, + alloc: A, + ) -> Result>, BridgeError<'heap>> + where + A: Clone, + { + debug_assert_eq!(body.id, self.body); + + let target = match self.target { + Hydrated::Null(()) => return Ok(None), + Hydrated::Skipped => { + return Err(BridgeError::IncompleteContinuation { + body: self.body, + field: "target", + }); + } + Hydrated::Value(target) => target, + }; + + let locals = match self.locals { + Optional::Null(()) | Optional::Skipped => { + return Err(BridgeError::IncompleteContinuation { + body: self.body, + field: "locals", + }); + } + Optional::Value(locals) => locals, + }; + let values = match self.values { + Optional::Null(()) | Optional::Skipped => { + return Err(BridgeError::IncompleteContinuation { + body: self.body, + field: "values", + }); + } + Optional::Value(values) => values, + }; + debug_assert_eq!(locals.len(), values.len()); + + let mut evaluated_locals = Vec::with_capacity_in(locals.len(), alloc); + + for (local, value) in locals.into_iter().zip(values) { + let r#type = body.local_decls[local].r#type; + + let value = decoder.decode(r#type, (&value).into()).map_err(|source| { + BridgeError::ContinuationDeserialization { + body: self.body, + local, + source, + } + })?; + evaluated_locals.push((local, value)); + } + + Ok(Some(PostgresState { + body: self.body, + island: self.island, + + target, + locals: evaluated_locals, + })) + } +} + +/// Validated continuation state ready to be applied to a [`CallStack`]. +/// +/// Contains the target [`BasicBlockId`] to jump to and the decoded local +/// variable bindings. Call [`flush`](Self::flush) to write these into the +/// callstack's current frame, advancing execution to the continuation point. +/// +/// [`CallStack`]: hashql_mir::interpret::CallStack +pub(crate) struct PostgresState<'heap, A: Allocator> { + pub body: DefId, + pub island: IslandId, + + target: BasicBlockId, + locals: Vec<(Local, Value<'heap, A>), A>, +} + +impl<'heap, A: Allocator> PostgresState<'heap, A> { + /// Writes the continuation state into `callstack`, setting the current + /// block to the target and populating locals with the decoded values. + pub(crate) fn flush<'ctx, E>( + &self, + callstack: &mut CallStack<'ctx, 'heap, A>, + ) -> Result<(), RuntimeError<'heap, E, A>> + where + A: Clone, + { + callstack.set_current_block_unchecked(self.target)?; + + // We must now advance the *last frame* (the current frame), with the current block + // (unsafely) + // And then get all locals and values into the frame + let frame_locals = callstack + .locals_mut() + .unwrap_or_else(|_err: RuntimeError<'heap, !, A>| unreachable!()); + + for (local, value) in &self.locals { + *frame_locals.local_mut(*local) = value.clone(); + } + + Ok(()) + } +} diff --git a/libs/@local/hashql/eval/src/orchestrator/request/graph_read.rs b/libs/@local/hashql/eval/src/orchestrator/request/graph_read.rs new file mode 100644 index 00000000000..da996456fac --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/request/graph_read.rs @@ -0,0 +1,450 @@ +//! Orchestrator for [`GraphRead`] suspensions. +//! +//! A [`GraphRead`] suspension is the interpreter's request to load vertices +//! from the graph store. The [`GraphReadOrchestrator`] resolves it by: +//! +//! 1. Looking up the pre-compiled SQL query for the suspension's `(body, block)` pair. +//! 2. Encoding the query parameters from the interpreter's current state. +//! 3. Executing the query against PostgreSQL and streaming rows. +//! 4. For each row: hydrating flat columns into a nested vertex [`Value`], decoding any +//! continuation state, running client-side filter chains (which may themselves involve +//! interpreter and postgres interleaving), and accumulating accepted values via a [`Tail`] +//! strategy. +//! 5. Packaging the collected output into a [`Continuation`] for the interpreter to resume with. +//! +//! [`GraphRead`]: hashql_mir::body::terminator::GraphRead +//! [`Value`]: hashql_mir::interpret::value::Value +//! [`Continuation`]: hashql_mir::interpret::suspension::Continuation +//! [`Tail`]: super::super::tail::Tail + +use core::{alloc::Allocator, pin::pin}; + +use futures_lite::StreamExt as _; +use hashql_mir::{ + body::terminator::{GraphRead, GraphReadBody}, + def::DefId, + interpret::{ + CallStack, Inputs, Runtime, RuntimeConfig, RuntimeError, Yield, + suspension::{Continuation, GraphReadSuspension}, + value::Value, + }, + pass::execution::TargetId, +}; +use tokio_postgres::{Client, Row}; + +use crate::{ + orchestrator::{ + Indexed, Orchestrator, + codec::{decode::Decoder, encode::encode_parameter_in}, + error::BridgeError, + events::{Event, EventLog}, + partial::Partial, + postgres::{PartialPostgresState, PostgresState}, + tail::Tail, + }, + postgres::{ColumnDescriptor, PreparedQuery}, +}; + +type PartialState<'heap, L> = (Partial<'heap, L>, Vec, L>); +type State<'heap, L> = (Value<'heap, L>, Vec, L>); + +/// Handler for [`GraphRead`] suspensions. +/// +/// Borrows the parent [`Orchestrator`] for access to the database client, +/// query registry, and evaluation context. All work happens through +/// [`fulfill_in`](Self::fulfill_in), which drives the full pipeline from +/// query execution through row hydration, filtering, and result collection. +/// +/// [`GraphRead`]: hashql_mir::body::terminator::GraphRead +/// [`Orchestrator`]: super::super::Orchestrator +pub(crate) struct GraphReadOrchestrator<'or, 'env, 'ctx, 'heap, C, E, A: Allocator> { + inner: &'or Orchestrator<'env, 'ctx, 'heap, C, E, A>, +} + +#[expect(clippy::future_not_send)] +impl<'or, 'env, 'ctx, 'heap, C: AsRef, E: EventLog, A: Allocator> + GraphReadOrchestrator<'or, 'env, 'ctx, 'heap, C, E, A> +{ + pub(crate) const fn new(orchestrator: &'or Orchestrator<'env, 'ctx, 'heap, C, E, A>) -> Self { + Self { + inner: orchestrator, + } + } + + fn postgres_hydrate_in( + &self, + decoder: &Decoder<'env, 'heap, L>, + + query: &PreparedQuery<'_, impl Allocator>, + row: &Row, + + alloc: L, + ) -> Result, RuntimeError<'heap, BridgeError<'heap>, L>> { + let mut partial = Partial::new(query.vertex_type); + let mut partial_states = Vec::new_in(alloc.clone()); + + for (index, &column) in query.columns.iter().enumerate() { + match column { + ColumnDescriptor::Path { path, r#type } => { + partial + .hydrate_from_postgres( + self.inner.context.env, + decoder, + path, + r#type, + Indexed::new(index, column), + row, + ) + .map_err(RuntimeError::Suspension)?; + } + ColumnDescriptor::Continuation { + body, + island, + field, + } => { + #[expect( + clippy::option_if_let_else, + reason = "this is required for borrowing because we borrow and push to \ + states" + )] + let state = if let Some(state) = partial_states.iter_mut().find( + |interpreter: &&mut PartialPostgresState<_>| { + interpreter.body == body && interpreter.island == island + }, + ) { + state + } else { + partial_states.push_mut(PartialPostgresState::new(body, island)) + }; + + state + .hydrate(Indexed::new(index, column), field, row, alloc.clone()) + .map_err(RuntimeError::Suspension)?; + } + } + } + + Ok((partial, partial_states)) + } + + fn finish_in( + &self, + + decoder: &Decoder<'env, 'heap, L>, + + partial: Partial<'heap, L>, + partial_states: Vec, L>, + + alloc: L, + ) -> Result, RuntimeError<'heap, BridgeError<'heap>, L>> { + let mut states = Vec::with_capacity_in(partial_states.len(), alloc.clone()); + + for state in partial_states { + let body = &self.inner.context.bodies[state.body]; + + let state = state + .finish_in(decoder, body, alloc.clone()) + .map_err(RuntimeError::Suspension)?; + + if let Some(state) = state { + states.push(state); + } + } + + let entity = partial.finish_in(self.inner.context.interner, alloc); + + Ok((entity, states)) + } + + #[expect(clippy::too_many_arguments)] + async fn process_row_filter_in( + &self, + inputs: &Inputs<'heap, L>, + + runtime: &mut Runtime<'ctx, 'heap, L>, + states: &[PostgresState<'heap, L>], + + body: DefId, + + entity: &Value<'heap, L>, + env: &Value<'heap, L>, + + alloc: L, + ) -> Result, L>> { + let residual = self.inner.context.execution.lookup(body).ok_or_else(|| { + RuntimeError::Suspension(BridgeError::MissingExecutionResidual { body }) + })?; + + let Ok(mut callstack) = CallStack::new_in( + &self.inner.context.bodies[body], + [Ok::<_, !>(env.clone()), Ok(entity.clone())], + alloc.clone(), + ); + + self.inner.event_log.log(Event::FilterStarted { body }); + + let eval = 'eval: loop { + let (island_id, island_node) = residual.islands.lookup(callstack.current_block()?); + let target = island_node.target(); + + self.inner.event_log.log(Event::IslandEntered { + body, + island: island_id, + target, + }); + + match target { + TargetId::Interpreter => { + loop { + let next = runtime.run_until_transition(&mut callstack, |target| { + residual.islands.lookup(target).0 == island_id + })?; + + match next { + core::ops::ControlFlow::Continue(Yield::Return(value)) => { + let Value::Integer(value) = value else { + return Err(RuntimeError::Suspension( + BridgeError::InvalidFilterReturn { body }, + )); + }; + + let Some(value) = value.as_bool() else { + return Err(RuntimeError::Suspension( + BridgeError::InvalidFilterReturn { body }, + )); + }; + + break 'eval value; + } + core::ops::ControlFlow::Continue(Yield::Suspension(suspension)) => { + let continuation = Box::pin(self.inner.fulfill_in( + inputs, + &callstack, + suspension, + alloc.clone(), + )) + .await?; + + continuation.apply(&mut callstack)?; + } + core::ops::ControlFlow::Break(_) => { + // We're finished, this means, and the next island is + // up. To determine the next island we simply break. + break; + } + } + } + } + TargetId::Postgres => { + // Postgres is special, because we hoist any computation directly + // into the initial query. + // There can be two different cases here: + // 1. The value is NULL, meaning that the filter has already been fully + // evaluated in the postgres query + // 2. The value is not NULL, which means that we need to continue evaluation of + // the filter body. + let Some(state) = states + .iter() + .find(|state| state.body == body && state.island == island_id) + else { + // This is the implicit value, in case that the where clause + // upstream has been evaluated. If the postgres query has + // produced a value, it must mean that the condition must've + // been true. + self.inner + .event_log + .log(Event::ContinuationImplicitTrue { body }); + break 'eval true; + }; + + // We must not flush the locals of the body to the values that have + // been captured, and advance the pointer. + state.flush(&mut callstack)?; + self.inner.event_log.log(Event::ContinuationFlushed { + body, + island: island_id, + }); + } + TargetId::Embedding => { + // TODO: in the future this may benefit from a dispatch barrier, the + // idea that we wait for sufficient embedding calls to the same + // island to dispatch. Must be smaller than the buffer size. + unimplemented!() + } + } + }; + + Ok(eval) + } + + async fn process_row_transform_in( + &self, + inputs: &Inputs<'heap, L>, + parent: &CallStack<'ctx, 'heap, L>, + + states: &[PostgresState<'heap, L>], + + entity: Value<'heap, L>, + + read: &GraphRead<'heap>, + + alloc: L, + ) -> Result>, RuntimeError<'heap, BridgeError<'heap>, L>> { + let mut runtime = Runtime::new_in( + RuntimeConfig::default(), + self.inner.context.bodies, + inputs, + alloc.clone(), + ); + + for body in &read.body { + match body { + &GraphReadBody::Filter(body, env) => { + let env = parent.locals()?.local(env)?; + + runtime.reset(); + let result = self + .process_row_filter_in( + inputs, + &mut runtime, + states, + body, + &entity, + env, + alloc.clone(), + ) + .await?; + + // Filters are short circuiting and act as `&&`, meaning if one is false, all + // are. + if result { + self.inner.event_log.log(Event::FilterAccepted { body }); + } else { + self.inner.event_log.log(Event::FilterRejected { body }); + return Ok(None); + } + } + } + } + + Ok(Some(entity)) + } + + async fn process_row_in( + &self, + inputs: &Inputs<'heap, L>, + parent: &CallStack<'ctx, 'heap, L>, + + read: &GraphRead<'heap>, + query: &PreparedQuery<'heap, impl Allocator>, + + row: Row, + + alloc: L, + ) -> Result>, RuntimeError<'heap, BridgeError<'heap>, L>> { + let decoder = Decoder::new( + self.inner.context.env, + self.inner.context.interner, + alloc.clone(), + ); + + let (partial, partial_states) = + self.postgres_hydrate_in(&decoder, query, &row, alloc.clone())?; + + let (entity, states) = self.finish_in(&decoder, partial, partial_states, alloc.clone())?; + + // Now that we have the completed states, it's time to fulfill the graph read, by running + // everything through the filter chain. + // This is sequential in nature, because in the future filters may depend on the mapped + // value. The parallelisation opportunity of sequential filters isn't applicable here, + // instead that should be done inside either the HIR or MIR. + self.process_row_transform_in(inputs, parent, &states, entity, read, alloc) + .await + } + + // The entrypoint for graph read operations. The entrypoint is *always* postgres, because that's + // the primary data store. + pub(crate) async fn fulfill_in( + &self, + inputs: &Inputs<'heap, L>, + callstack: &CallStack<'ctx, 'heap, L>, + suspension @ GraphReadSuspension { + body, + block, + read, + axis: _, + }: GraphReadSuspension<'ctx, 'heap>, + alloc: L, + ) -> Result, RuntimeError<'heap, BridgeError<'heap>, L>> { + // Because postgres is our source of truth, it means that any graph read suspension must be + // resolved by querying postgres first. + let query = + self.inner.queries.find(body, block).ok_or_else(|| { + RuntimeError::Suspension(BridgeError::QueryLookup { body, block }) + })?; + let statement = query.transpile().to_string(); + + let locals = callstack.locals().map_err(RuntimeError::widen)?; + let mut params = Vec::with_capacity_in(query.parameters.len(), alloc.clone()); + for param in query.parameters.iter().map(|parameter| { + encode_parameter_in( + parameter, + inputs, + &suspension.axis, + |local, field| { + let value = locals.local(local)?; + value.project(field) + }, + alloc.clone(), + ) + }) { + params.push(param?); + } + + self.inner + .event_log + .log(Event::QueryExecuted { body, block }); + + // The actual data and entities that we need to take a look at. + let response = self + .inner + .client + .as_ref() + .query_raw(&statement, params.iter().map(|param| &**param)) + .await + .map_err(|source| BridgeError::QueryExecution { + sql: statement.clone(), + source, + }) + .map_err(RuntimeError::Suspension)?; + + let mut response = pin!(response); + + // TODO: parallelisation opportunity + let mut output = Tail::new(read.tail); + while let Some(row) = response.next().await { + let row = row + .map_err(|error| BridgeError::QueryExecution { + sql: statement.clone(), + source: error, + }) + .map_err(RuntimeError::Suspension)?; + + self.inner.event_log.log(Event::RowReceived); + + let item = self + .process_row_in(inputs, callstack, read, query, row, alloc.clone()) + .await?; + + if let Some(item) = item { + self.inner.event_log.log(Event::RowAccepted); + output.push(item); + } else { + self.inner.event_log.log(Event::RowRejected); + } + } + + let output = output.finish(); + Ok(suspension.resolve(output)) + } +} diff --git a/libs/@local/hashql/eval/src/orchestrator/request/mod.rs b/libs/@local/hashql/eval/src/orchestrator/request/mod.rs new file mode 100644 index 00000000000..a274e6d7a1e --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/request/mod.rs @@ -0,0 +1,10 @@ +//! Per-suspension-type request handlers. +//! +//! Each suspension variant has a dedicated orchestrator that knows how to +//! fulfill it. Currently the only variant is [`GraphRead`], handled by +//! [`GraphReadOrchestrator`]. +//! +//! [`GraphRead`]: hashql_mir::body::terminator::GraphRead + +mod graph_read; +pub(crate) use self::graph_read::GraphReadOrchestrator; diff --git a/libs/@local/hashql/eval/src/orchestrator/tail.rs b/libs/@local/hashql/eval/src/orchestrator/tail.rs new file mode 100644 index 00000000000..c155be562e1 --- /dev/null +++ b/libs/@local/hashql/eval/src/orchestrator/tail.rs @@ -0,0 +1,49 @@ +//! Result accumulation strategies for graph read operations. +//! +//! After each row is hydrated and passes any filter chains, the resulting +//! [`Value`] must be collected into a final output. The [`Tail`] enum +//! determines the accumulation strategy, currently only [`Collect`], which +//! gathers all values into a [`List`]. +//! +//! [`Value`]: hashql_mir::interpret::value::Value +//! [`Collect`]: Tail::Collect +//! [`List`]: hashql_mir::interpret::value::List + +use core::alloc::Allocator; + +use hashql_mir::{ + body::terminator::GraphReadTail, + interpret::value::{self, Value}, +}; + +/// Accumulator for row results, determined by the [`GraphReadTail`] variant. +/// +/// Created once per graph read suspension, receives each post-filter value via +/// [`push`](Self::push), and produces the final output via +/// [`finish`](Self::finish). +pub(crate) enum Tail<'heap, A: Allocator> { + Collect(value::List<'heap, A>), +} + +impl<'heap, A: Allocator> Tail<'heap, A> { + pub(crate) fn new(tail: GraphReadTail) -> Self { + match tail { + GraphReadTail::Collect => Self::Collect(value::List::new()), + } + } + + pub(crate) fn push(&mut self, value: value::Value<'heap, A>) + where + A: Clone, + { + match self { + Self::Collect(list) => list.push_back(value), + } + } + + pub(crate) fn finish(self) -> Value<'heap, A> { + match self { + Self::Collect(list) => Value::List(list), + } + } +} diff --git a/libs/@local/hashql/eval/src/postgres/continuation.rs b/libs/@local/hashql/eval/src/postgres/continuation.rs index b722e1e0ab9..749d64ecc5e 100644 --- a/libs/@local/hashql/eval/src/postgres/continuation.rs +++ b/libs/@local/hashql/eval/src/postgres/continuation.rs @@ -45,6 +45,32 @@ impl ContinuationAlias { } } +/// Continuation fields returned to the bridge in the `SELECT` list. +/// +/// A subset of `ContinuationColumn` that excludes internal-only columns +/// (`Entry` and `Filter`). +/// Each variant corresponds to a column the bridge must decode to reconstruct +/// island exit control flow and live-out locals. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum ContinuationField { + /// The target basic block id for island exits. + Block, + /// Array of local ids being transferred on island exit. + Locals, + /// Array of jsonb values corresponding to [`Self::Locals`]. + Values, +} + +impl From for ContinuationColumn { + fn from(value: ContinuationField) -> Self { + match value { + ContinuationField::Block => Self::Block, + ContinuationField::Locals => Self::Locals, + ContinuationField::Values => Self::Values, + } + } +} + /// All column names used within the continuation LATERAL subquery and the /// `continuation` composite type. /// diff --git a/libs/@local/hashql/eval/src/postgres/filter/mod.rs b/libs/@local/hashql/eval/src/postgres/filter/mod.rs index 7e6eb4ac88d..f900b3f08f0 100644 --- a/libs/@local/hashql/eval/src/postgres/filter/mod.rs +++ b/libs/@local/hashql/eval/src/postgres/filter/mod.rs @@ -96,7 +96,10 @@ impl From for Expression { let row = match continuation { Continuation::Return { filter } => { vec![ - filter.grouped().cast(PostgresType::Boolean), + filter + .grouped() + .cast(PostgresType::Boolean) + .coalesce(Self::Constant(query::Constant::Boolean(false))), null.clone(), null.clone(), null, @@ -184,7 +187,20 @@ fn finish_switch_int( let discriminant = Box::new(discriminant.grouped().cast(PostgresType::Int)); let mut discriminant = Some(discriminant); - let mut conditions = Vec::with_capacity(targets.values().len()); + // +1 for the NULL guard: a NULL discriminant means the computation could + // not be evaluated (e.g. missing JSONB key), so we reject the row. + let mut conditions = Vec::with_capacity(targets.values().len() + 1); + + conditions.push(( + Expression::Unary(UnaryExpression { + op: UnaryOperator::IsNull, + expr: discriminant.clone().unwrap_or_else(|| unreachable!()), + }), + Continuation::Return { + filter: Expression::Constant(query::Constant::Boolean(false)), + } + .into(), + )); for (index, (&value, then)) in targets.values().iter().zip(branch_results).enumerate() { let is_last = index == targets.values().len() - 1; @@ -219,6 +235,8 @@ pub(crate) struct GraphReadFilterCompiler<'ctx, 'heap, A: Allocator = Global, S: context: &'ctx EvalContext<'ctx, 'heap, A>, body: &'ctx Body<'heap>, + env: Local, + /// MIR local → SQL expression mapping, with snapshot/rollback for branching. locals: LocalSnapshotVec, AppendOnly, S>, diagnostics: EvalDiagnosticIssues, @@ -230,6 +248,7 @@ impl<'ctx, 'heap, A: Allocator, S: Allocator> GraphReadFilterCompiler<'ctx, 'hea pub(crate) fn new( context: &'ctx EvalContext<'ctx, 'heap, A>, body: &'ctx Body<'heap>, + env: Local, scratch: S, ) -> Self where @@ -238,6 +257,7 @@ impl<'ctx, 'heap, A: Allocator, S: Allocator> GraphReadFilterCompiler<'ctx, 'hea Self { context, body, + env, locals: IdSnapshotVec::new_in(scratch.clone()), diagnostics: DiagnosticIssues::new(), scratch, @@ -295,8 +315,8 @@ impl<'ctx, 'heap, A: Allocator, S: Allocator> GraphReadFilterCompiler<'ctx, 'hea }, rest @ .., ] => { - let param = db.parameters.env(self.body.id, *field); - (param.into(), rest) + let param = db.parameters.env(self.env, *field); + (param.to_expr(), rest) } [..] => { self.diagnostics.push(invalid_env_projection(span)); @@ -334,7 +354,7 @@ impl<'ctx, 'heap, A: Allocator, S: Allocator> GraphReadFilterCompiler<'ctx, 'hea ProjectionKind::Field(field_index) => { Expression::Constant(query::Constant::U32(field_index.as_u32())) } - &ProjectionKind::FieldByName(symbol) => db.parameters.symbol(symbol).into(), + &ProjectionKind::FieldByName(symbol) => db.parameters.symbol(symbol).to_expr(), &ProjectionKind::Index(local) => self .locals .lookup(local) @@ -363,8 +383,8 @@ impl<'ctx, 'heap, A: Allocator, S: Allocator> GraphReadFilterCompiler<'ctx, 'hea Constant::Int(int) if let Ok(uint) = u32::try_from(int.as_uint()) => { Expression::Constant(query::Constant::U32(uint)) } - &Constant::Int(int) => db.parameters.int(int).into(), - &Constant::Primitive(primitive) => db.parameters.primitive(primitive).into(), + &Constant::Int(int) => db.parameters.int(int).to_expr(), + &Constant::Primitive(primitive) => db.parameters.primitive(primitive).to_expr(), // Unit is the zero-sized type, represented as JSON `null` inside jsonb values. Constant::Unit => Expression::Constant(query::Constant::JsonNull), Constant::FnPtr(_) => { @@ -418,17 +438,21 @@ impl<'ctx, 'heap, A: Allocator, S: Allocator> GraphReadFilterCompiler<'ctx, 'hea // Operands coming from jsonb extraction are untyped from Postgres' perspective. // Arithmetic and bitwise operators need explicit casts; comparisons work on jsonb // directly. - let (op, cast) = match *op { - BinOp::Add => (BinaryOperator::Add, Some(PostgresType::Numeric)), - BinOp::Sub => (BinaryOperator::Subtract, Some(PostgresType::Numeric)), - BinOp::BitAnd => (BinaryOperator::BitwiseAnd, Some(PostgresType::BigInt)), - BinOp::BitOr => (BinaryOperator::BitwiseOr, Some(PostgresType::BigInt)), - BinOp::Eq => (BinaryOperator::Equal, None), - BinOp::Ne => (BinaryOperator::NotEqual, None), - BinOp::Lt => (BinaryOperator::Less, None), - BinOp::Lte => (BinaryOperator::LessOrEqual, None), - BinOp::Gt => (BinaryOperator::Greater, None), - BinOp::Gte => (BinaryOperator::GreaterOrEqual, None), + let (op, cast, function) = match *op { + BinOp::Add => (BinaryOperator::Add, Some(PostgresType::Numeric), None), + BinOp::Sub => (BinaryOperator::Subtract, Some(PostgresType::Numeric), None), + BinOp::BitAnd => (BinaryOperator::BitwiseAnd, Some(PostgresType::BigInt), None), + BinOp::BitOr => (BinaryOperator::BitwiseOr, Some(PostgresType::BigInt), None), + BinOp::Eq => (BinaryOperator::Equal, None, Some(query::Function::ToJson)), + BinOp::Ne => ( + BinaryOperator::NotEqual, + None, + Some(query::Function::ToJson), + ), + BinOp::Lt => (BinaryOperator::Less, None, None), + BinOp::Lte => (BinaryOperator::LessOrEqual, None, None), + BinOp::Gt => (BinaryOperator::Greater, None, None), + BinOp::Gte => (BinaryOperator::GreaterOrEqual, None, None), }; if let Some(target) = cast { @@ -436,6 +460,11 @@ impl<'ctx, 'heap, A: Allocator, S: Allocator> GraphReadFilterCompiler<'ctx, 'hea right = right.grouped().cast(target); } + if let Some(function) = function { + left = Expression::Function(function(Box::new(left))); + right = Expression::Function(function(Box::new(right))); + } + Expression::Binary(BinaryExpression { op, left: Box::new(left), @@ -450,12 +479,12 @@ impl<'ctx, 'heap, A: Allocator, S: Allocator> GraphReadFilterCompiler<'ctx, 'hea let index = db.parameters.input(*name); match *op { - InputOp::Load { required: _ } => index.into(), + InputOp::Load { required: _ } => index.to_expr(), InputOp::Exists => Expression::Unary(UnaryExpression { op: UnaryOperator::Not, expr: Box::new(Expression::Unary(UnaryExpression { op: UnaryOperator::IsNull, - expr: Box::new(index.into()), + expr: Box::new(index.to_expr()), })), }), } @@ -488,7 +517,7 @@ impl<'ctx, 'heap, A: Allocator, S: Allocator> GraphReadFilterCompiler<'ctx, 'hea let key = db.parameters.symbol(key); let value = self.compile_operand(db, span, value); - expressions.push((key.into(), value)); + expressions.push((key.to_expr(), value)); } // Values are reconstructed to their corresponding tuple and struct definitions diff --git a/libs/@local/hashql/eval/src/postgres/filter/tests.rs b/libs/@local/hashql/eval/src/postgres/filter/tests.rs index 44f8ffc1cdc..d627b469128 100644 --- a/libs/@local/hashql/eval/src/postgres/filter/tests.rs +++ b/libs/@local/hashql/eval/src/postgres/filter/tests.rs @@ -15,6 +15,7 @@ use hash_graph_postgres_store::store::postgres::query::{Expression, Transpile as use hashql_core::{ heap::{Heap, Scratch}, id::Id as _, + module::std_lib::graph::types::knowledge::entity as entity_types, pretty::Formatter, symbol::sym, r#type::{TypeBuilder, TypeFormatter, TypeFormatterOptions, TypeId, environment::Environment}, @@ -148,9 +149,11 @@ fn format_body<'heap>(fixture: &Fixture<'heap>, heap: &'heap Heap) -> String { fn compile_filter_islands<'heap>(fixture: &Fixture<'heap>, heap: &'heap Heap) -> FilterReport { let mut scratch = Scratch::new(); let def = fixture.def(); + let interner = Interner::new(heap); let context = EvalContext::new_in( &fixture.env, + &interner, &fixture.bodies, &fixture.execution, heap, @@ -180,7 +183,7 @@ fn compile_filter_islands<'heap>(fixture: &Fixture<'heap>, heap: &'heap Heap) -> let island = &residual.islands[island_id]; let mut db = DatabaseContext::new_in(heap); - let mut compiler = GraphReadFilterCompiler::new(&context, body, Global); + let mut compiler = GraphReadFilterCompiler::new(&context, body, Local::ENV, Global); let expression = compiler.compile_body(&mut db, island); let diagnostics = compiler.into_diagnostics(); @@ -255,9 +258,11 @@ fn compile_full_query_with_mask<'heap>( ) -> QueryReport { let mut scratch = Scratch::new(); let def = fixture.def(); + let interner = Interner::new(heap); let mut context = EvalContext::new_in( &fixture.env, + &interner, &fixture.bodies, &fixture.execution, heap, @@ -281,7 +286,7 @@ fn compile_full_query_with_mask<'heap>( let prepared_query = { let mut compiler = PostgresCompiler::new_in(&mut context, &mut scratch).with_property_mask(property_mask); - compiler.compile(&read) + compiler.compile_graph_read(&read) }; assert!( @@ -590,10 +595,10 @@ fn data_island_provides_without_lateral() { let callee_id = DefId::new(99); - // Light entity path accesses — solver puts everything on Interpreter, creating only a + // Light entity path accesses: solver puts everything on Interpreter, creating only a // Postgres Data island for the entity columns. No Postgres exec island exists. let body = body!(interner, env; [graph::read::filter]@0/2 -> ? { - decl env: (), vertex: [Opaque sym::path::Entity; ?], + decl env: (), vertex: (|t| entity_types::types::entity(t, t.unknown(), None)), uuid: ?, func: [fn() -> ?], result: ?; @proj v_uuid = vertex.entity_uuid: ?; @@ -640,7 +645,7 @@ fn provides_drives_select_and_joins() { // bb0 accesses entity paths (Postgres-origin), then bb1 uses a closure (Interpreter). // The Postgres island should provide the accessed paths to the Interpreter island. let body = body!(interner, env; [graph::read::filter]@0/2 -> ? { - decl env: (), vertex: [Opaque sym::path::Entity; ?], + decl env: (), vertex: (|t| entity_types::types::entity(t, t.unknown(), None)), uuid: ?, archived: ?, func: [fn() -> ?], result: ?; @proj v_uuid = vertex.entity_uuid: ?, v_metadata = vertex.metadata: ?, @@ -771,7 +776,7 @@ fn property_mask() { // Properties access in bb0 (Postgres Data island) with an apply in bb1 (Interpreter) // ensures Properties and `PropertyMetadata` appear in the provides set. let body = body!(interner, env; [graph::read::filter]@0/2 -> ? { - decl env: (), vertex: [Opaque sym::path::Entity; ?], + decl env: (), vertex: (|t| entity_types::types::entity(t, t.unknown(), None)), props: ?, prop_meta: ?, func: [fn() -> ?], result: ?; @proj v_props = vertex.properties: ?, v_meta = vertex.metadata: ?, @@ -1011,6 +1016,43 @@ fn unary_bitnot() { assert_snapshot!("unary_bitnot", report.to_string()); } +/// Temporal leaf path: `vertex.metadata.temporal_versioning.decision_time` decomposes +/// the `tstzrange` column into a structured interval with `lower`/`upper`/`lower_inc`/ +/// `upper_inc`/`lower_inf` and epoch-millisecond extraction. +#[test] +fn temporal_decision_time_interval() { + let heap = Heap::new(); + let interner = Interner::new(&heap); + let env = Environment::new(&heap); + + let callee_id = DefId::new(99); + + let body = body!(interner, env; [graph::read::filter]@0/2 -> ? { + decl env: (), vertex: [Opaque sym::path::Entity; ?], + decision: ?, func: [fn() -> ?], result: ?; + @proj v_meta = vertex.metadata: ?, + v_temporal = v_meta.temporal_versioning: ?, + v_decision = v_temporal.decision_time: ?; + + bb0() { + decision = load v_decision; + goto bb1(); + }, + bb1() { + func = load callee_id; + result = apply func; + return result; + } + }); + + let fixture = Fixture::new(&heap, env, body); + let report = compile_full_query(&fixture, &heap); + + let settings = snapshot_settings(); + let _guard = settings.bind_to_scope(); + assert_snapshot!("temporal_decision_time_interval", report.to_string()); +} + /// `BinOp::BitAnd` → `BinaryOperator::BitwiseAnd` with `::bigint` casts on both operands. #[test] fn binary_bitand_bigint_cast() { diff --git a/libs/@local/hashql/eval/src/postgres/mod.rs b/libs/@local/hashql/eval/src/postgres/mod.rs index 24c8d72bcf6..71743748071 100644 --- a/libs/@local/hashql/eval/src/postgres/mod.rs +++ b/libs/@local/hashql/eval/src/postgres/mod.rs @@ -36,25 +36,36 @@ use hash_graph_postgres_store::store::postgres::query::{ self, Column, Expression, Identifier, SelectExpression, SelectStatement, Transpile as _, WhereExpression, table::EntityTemporalMetadata, }; -use hashql_core::heap::BumpAllocator; +use hashql_core::{ + debug_panic, + heap::BumpAllocator, + id::Id as _, + r#type::{TypeBuilder, TypeId, environment::LatticeEnvironment}, +}; use hashql_mir::{ body::{ Body, - terminator::{GraphRead, GraphReadBody}, + basic_block::BasicBlockId, + local::Local, + terminator::{GraphRead, GraphReadBody, GraphReadHead, TerminatorKind}, }, - def::DefId, + def::{DefId, DefIdSlice}, pass::{ analysis::dataflow::lattice::HasBottom as _, execution::{ - IslandKind, IslandNode, TargetId, VertexType, + IslandId, IslandKind, IslandNode, TargetId, VertexType, traversal::{EntityPath, TraversalMapLattice, TraversalPath, TraversalPathBitMap}, }, }, }; -pub use self::parameters::{ParameterIndex, Parameters, TemporalAxis}; use self::{ continuation::ContinuationColumn, filter::GraphReadFilterCompiler, projections::Projections, + types::traverse_struct, +}; +pub use self::{ + continuation::ContinuationField, + parameters::{Parameter, ParameterIndex, ParameterValue, Parameters, TemporalAxis}, }; use crate::context::EvalContext; @@ -64,6 +75,7 @@ mod filter; mod parameters; mod projections; mod traverse; +mod types; /// Mutable compilation state accumulated while building a single SQL query. /// @@ -113,8 +125,11 @@ impl DatabaseContext<'_, A> { let tx_param = self .parameters .temporal_axis(TemporalAxis::Transaction) - .into(); - let dt_param = self.parameters.temporal_axis(TemporalAxis::Decision).into(); + .to_expr(); + let dt_param = self + .parameters + .temporal_axis(TemporalAxis::Decision) + .to_expr(); self.where_expression.add_condition(Expression::overlap( Expression::ColumnReference(query::ColumnReference { @@ -135,13 +150,57 @@ impl DatabaseContext<'_, A> { } } +/// Describes a single column in the `SELECT` list of a compiled query. +/// +/// The bridge uses this manifest to decode each column in a result row without +/// parsing column names. Entity field columns carry a [`TraversalPath`] for +/// hydration; continuation columns carry the body/island identity for routing +/// control flow back to the interpreter. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum ColumnDescriptor { + /// An entity field produced by the provides set. + /// + /// The [`TraversalPath`] identifies the storage location; the [`TypeId`] is the + /// field's type within the instantiated vertex type, used for type-directed + /// deserialization. + Path { path: TraversalPath, r#type: TypeId }, + /// A decomposed continuation field from an island's `CROSS JOIN LATERAL`. + Continuation { + body: DefId, + island: IslandId, + field: ContinuationField, + }, +} + +impl Display for ColumnDescriptor { + fn fmt(&self, fmt: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::Path { path, .. } => write!(fmt, "entity path `{}`", path.as_symbol()), + Self::Continuation { + body, + island, + field, + } => { + write!( + fmt, + "continuation {} (body {body}, island {island})", + ContinuationColumn::from(*field).as_str() + ) + } + } + } +} + /// A fully-compiled SQL query ready for execution. /// -/// Contains the typed query AST ([`SelectStatement`]) and the parameter catalog ([`Parameters`]) -/// that the interpreter uses to bind runtime values in the correct order. +/// Contains the typed query AST ([`SelectStatement`]), the parameter catalog ([`Parameters`]) +/// for binding runtime values, and a column manifest ([`ColumnDescriptor`]s) that tells the +/// bridge how to decode each result column. pub struct PreparedQuery<'heap, A: Allocator> { + pub vertex_type: VertexType, pub parameters: Parameters<'heap, A>, pub statement: SelectStatement, + pub columns: Vec, } impl PreparedQuery<'_, A> { @@ -150,6 +209,32 @@ impl PreparedQuery<'_, A> { } } +/// Registry of compiled SQL queries, indexed by definition and basic block. +/// +/// The SQL lowering pass produces one [`PreparedQuery`] per [`GraphRead`] +/// terminator in the MIR. This struct stores them contiguously in `queries` +/// with `offsets` providing per-definition starting positions, so +/// [`find`](Self::find) can locate the correct query for a given `(DefId, +/// BasicBlockId)` pair. +/// +/// [`GraphRead`]: hashql_mir::body::terminator::GraphRead +pub struct PreparedQueries<'heap, A: Allocator> { + offsets: Box, A>, + queries: Vec<(BasicBlockId, PreparedQuery<'heap, A>), A>, +} + +impl<'heap, A: Allocator> PreparedQueries<'heap, A> { + pub fn find(&self, body: DefId, block: BasicBlockId) -> Option<&PreparedQuery<'heap, A>> { + let start = self.offsets[body]; + let end = self.offsets[body.plus(1)]; + + self.queries[start..end] + .iter() + .find(|(id, _)| *id == block) + .map(|(_, query)| query) + } +} + /// Compiles Postgres-targeted MIR islands into a single PostgreSQL `SELECT`. /// /// Created per evaluation and used to compile [`GraphRead`] terminators. Compilation emits @@ -200,11 +285,42 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> self } + /// Joins the property types across all filter bodies into a single type. + /// + /// Each filter body may operate on a different `Entity`. This computes the + /// least upper bound of all the `T` parameters, producing the unified property type + /// for the query's SELECT list. Returns `unknown` if there are no filter bodies. + fn resolve_property_type(&self, read: &GraphRead<'heap>) -> TypeId { + let mut lattice = LatticeEnvironment::new(self.context.env).without_warnings(); + + read.body + .iter() + .map(|body| match body { + &GraphReadBody::Filter(def_id, _) => { + let vertex = self.context.bodies[def_id].local_decls[Local::VERTEX].r#type; + + let path = EntityPath::Properties.field_path(); + + traverse_struct(self.context.env, vertex, path).unwrap_or_else(|| { + debug_panic!( + "failed to extract property type from vertex type {vertex:?}; the \ + vertex type should contain a resolvable properties field" + ); + + TypeBuilder::synthetic(self.context.env).unknown() + }) + } + }) + .reduce(|lhs, rhs| lattice.join(lhs, rhs)) + .unwrap_or_else(|| TypeBuilder::synthetic(self.context.env).unknown()) + } + /// Returns `None` for data-only islands that produce no SQL. fn compile_graph_read_filter_island( &mut self, db: &mut DatabaseContext<'heap, A>, body: &Body<'heap>, + env: Local, island: &IslandNode, provides: &mut TraversalPathBitMap, ) -> Option { @@ -219,7 +335,7 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> // TODO: we might want a longer lived graph read filter compiler here let expression = self.scratch.scoped(|alloc| { - let mut compiler = GraphReadFilterCompiler::new(self.context, body, &alloc); + let mut compiler = GraphReadFilterCompiler::new(self.context, body, env, &alloc); let expression = compiler.compile_body(db, island); let mut diagnostics = compiler.into_diagnostics(); @@ -236,6 +352,7 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> &mut self, db: &mut DatabaseContext<'heap, A>, def: DefId, + env: Local, provides: &mut TraversalPathBitMap, ) { let body = &self.context.bodies[def]; @@ -251,7 +368,7 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> for (island_id, island) in islands { let Some(expression) = - self.compile_graph_read_filter_island(db, body, island, provides) + self.compile_graph_read_filter_island(db, body, env, island, provides) else { continue; }; @@ -287,15 +404,14 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> } } - /// Compiles a [`GraphRead`] into a [`PreparedQuery`]. - /// - /// [`GraphRead`]: hashql_mir::body::terminator::GraphRead - pub fn compile(&mut self, read: &'ctx GraphRead<'heap>) -> PreparedQuery<'heap, A> + fn compile_graph_read_entity(&mut self, read: &GraphRead<'heap>) -> PreparedQuery<'heap, A> where A: Clone, { let mut db = DatabaseContext::new_in(self.alloc.clone()); + let mut property_type = None; + // Temporal conditions go first - they're always present on the base table // and don't depend on anything the filter body produces. db.add_temporal_conditions(); @@ -304,8 +420,8 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> for body in &read.body { match body { - &GraphReadBody::Filter(def_id, _) => { - self.compile_graph_read_filter(&mut db, def_id, &mut provides); + &GraphReadBody::Filter(def_id, env) => { + self.compile_graph_read_filter(&mut db, def_id, env, &mut provides); } } } @@ -314,6 +430,7 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> // Each EntityPath in `provides` becomes a SELECT expression via eval_entity_path, // which also registers the necessary projection joins in DatabaseContext. let mut select_expressions = vec![]; + let mut columns = Vec::new_in(self.alloc.clone()); for traversal_path in provides[VertexType::Entity].iter() { let TraversalPath::Entity(path) = traversal_path; @@ -328,10 +445,20 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> let alias = Identifier::from(traversal_path.as_symbol().unwrap()); + let field_type = traversal_path + .resolve_type(self.context.env) + .unwrap_or_else(|| { + *property_type.get_or_insert_with(|| self.resolve_property_type(read)) + }); + select_expressions.push(SelectExpression::Expression { expression, alias: Some(alias), }); + columns.push(ColumnDescriptor::Path { + path: traversal_path, + r#type: field_type, + }); } // Decompose each continuation LATERAL into individual columns so the @@ -341,13 +468,18 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> let table_ref = cont_alias.table_ref(); for field in [ - ContinuationColumn::Block, - ContinuationColumn::Locals, - ContinuationColumn::Values, + ContinuationField::Block, + ContinuationField::Locals, + ContinuationField::Values, ] { select_expressions.push(SelectExpression::Expression { - expression: continuation::field_access(&table_ref, field), - alias: Some(cont_alias.field_identifier(field)), + expression: continuation::field_access(&table_ref, field.into()), + alias: Some(cont_alias.field_identifier(field.into())), + }); + columns.push(ColumnDescriptor::Continuation { + body: cont_alias.body, + island: cont_alias.island, + field, }); } } @@ -371,8 +503,57 @@ impl<'eval, 'ctx, 'heap, A: Allocator, S: BumpAllocator> .build(); PreparedQuery { + vertex_type: VertexType::Entity, parameters: db.parameters, statement: query, + columns, } } + + /// Compiles a [`GraphRead`] into a [`PreparedQuery`]. + /// + /// [`GraphRead`]: hashql_mir::body::terminator::GraphRead + pub fn compile_graph_read(&mut self, read: &'ctx GraphRead<'heap>) -> PreparedQuery<'heap, A> + where + A: Clone, + { + match read.head { + GraphReadHead::Entity { .. } => self.compile_graph_read_entity(read), + } + } + + #[expect(unsafe_code)] + pub fn compile(&mut self) -> PreparedQueries<'heap, A> + where + A: Clone, + { + // SAFETY: 0 is a valid value for `usize` + let offsets = unsafe { + Box::new_zeroed_slice_in(self.context.bodies.len() + 1, self.alloc.clone()) + .assume_init() + }; + let mut offsets = DefIdSlice::from_boxed_slice(offsets); + + let mut queries = Vec::with_capacity_in(self.context.bodies.len(), self.alloc.clone()); + + let bodies = self.context.bodies; + for (body_id, body) in bodies.iter_enumerated() { + for (block_id, block) in body.basic_blocks.iter_enumerated() { + match &block.terminator.kind { + TerminatorKind::GraphRead(read) => { + let query = self.compile_graph_read(read); + queries.push((block_id, query)); + } + TerminatorKind::Goto(_) + | TerminatorKind::SwitchInt(_) + | TerminatorKind::Return(_) + | TerminatorKind::Unreachable => {} + } + } + + offsets[body_id.plus(1)] = queries.len(); + } + + PreparedQueries { offsets, queries } + } } diff --git a/libs/@local/hashql/eval/src/postgres/parameters.rs b/libs/@local/hashql/eval/src/postgres/parameters.rs index a85a7127db8..411a93544d9 100644 --- a/libs/@local/hashql/eval/src/postgres/parameters.rs +++ b/libs/@local/hashql/eval/src/postgres/parameters.rs @@ -11,14 +11,17 @@ use core::{ fmt::{self, Display}, }; -use hash_graph_postgres_store::store::postgres::query::Expression; +use hash_graph_postgres_store::store::postgres::query::{Expression, PostgresType}; use hashql_core::{ collections::{FastHashMap, fast_hash_map_in}, id::{self, Id as _, IdVec}, symbol::Symbol, value::Primitive, }; -use hashql_mir::{body::place::FieldIndex, def::DefId, interpret::value::Int}; +use hashql_mir::{ + body::{local::Local, place::FieldIndex}, + interpret::value::Int, +}; id::newtype!( /// Index of a SQL parameter in the compiled query, rendered as `$N` by the SQL formatter. @@ -38,12 +41,48 @@ impl From for Expression { } } +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub enum ParameterKind { + Value, + String, + Integer, + Boolean, + Number, + TimestampInterval, +} + +impl From for PostgresType { + fn from(value: ParameterKind) -> Self { + match value { + ParameterKind::Value => Self::JsonB, + ParameterKind::String => Self::Text, + ParameterKind::Integer => Self::BigInt, + ParameterKind::Boolean => Self::Boolean, + ParameterKind::Number => Self::Numeric, + ParameterKind::TimestampInterval => Self::TimestampTzRange, + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +pub struct Parameter { + pub index: ParameterIndex, + pub kind: ParameterKind, +} + +impl Parameter { + #[must_use] + pub fn to_expr(self) -> Expression { + Expression::Cast(Box::new(self.index.into()), PostgresType::from(self.kind)) + } +} + /// Interned identity for a SQL parameter. /// /// Parameters are deduplicated by this key so multiple occurrences of the same logical value /// (e.g. the same input symbol) share one `$N` placeholder. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -enum Parameter<'heap> { +pub enum ParameterValue<'heap> { /// A user-provided input binding. Input(Symbol<'heap>), /// An integer constant that does not fit in a `u32`. @@ -53,7 +92,7 @@ enum Parameter<'heap> { /// A symbol used as a JSON object key in SQL expressions. Symbol(Symbol<'heap>), /// A captured-environment field access. - Env(DefId, FieldIndex), + Env(Local, FieldIndex), /// Temporal axis range provided by the interpreter at execution time. /// /// The interpreter binds these based on the user's temporal axes configuration: @@ -62,14 +101,31 @@ enum Parameter<'heap> { TemporalAxis(TemporalAxis), } -impl fmt::Display for Parameter<'_> { +impl ParameterValue<'_> { + #[must_use] + pub const fn kind(&self) -> ParameterKind { + match self { + Self::Int(int) if int.is_bool() => ParameterKind::Boolean, + Self::Int(_) | Self::Primitive(Primitive::Integer(_)) => ParameterKind::Integer, + Self::Primitive(Primitive::Boolean(_)) => ParameterKind::Boolean, + Self::Primitive(Primitive::Float(_)) => ParameterKind::Number, + Self::Primitive(Primitive::String(_)) | Self::Symbol(_) => ParameterKind::String, + Self::Input(_) | Self::Primitive(Primitive::Null) | Self::Env(_, _) => { + ParameterKind::Value + } + Self::TemporalAxis(_) => ParameterKind::TimestampInterval, + } + } +} + +impl fmt::Display for ParameterValue<'_> { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Input(symbol) => write!(fmt, "Input({symbol})"), Self::Int(int) => write!(fmt, "Int({int})"), Self::Primitive(primitive) => write!(fmt, "Primitive({primitive})"), Self::Symbol(symbol) => write!(fmt, "Symbol({symbol})"), - Self::Env(def, field) => write!(fmt, "Env({def}, #{})", field.as_u32()), + Self::Env(local, field) => write!(fmt, "Env({local}, #{})", field.as_u32()), Self::TemporalAxis(axis) => write!(fmt, "TemporalAxis({axis})"), } } @@ -100,8 +156,8 @@ impl fmt::Display for TemporalAxis { /// /// The interpreter uses the reverse mapping to bind runtime values in the correct order. pub struct Parameters<'heap, A: Allocator = Global> { - lookup: FastHashMap, ParameterIndex, A>, - reverse: IdVec, A>, + lookup: FastHashMap, ParameterIndex, A>, + reverse: IdVec, A>, } impl<'heap, A: Allocator> Parameters<'heap, A> { @@ -115,36 +171,39 @@ impl<'heap, A: Allocator> Parameters<'heap, A> { } } - fn get_or_insert(&mut self, param: Parameter<'heap>) -> ParameterIndex { - *self + fn get_or_insert(&mut self, param: ParameterValue<'heap>) -> Parameter { + let kind = param.kind(); + let index = *self .lookup .entry(param) - .or_insert_with(|| self.reverse.push(param)) + .or_insert_with(|| self.reverse.push(param)); + + Parameter { index, kind } } - pub(crate) fn input(&mut self, name: Symbol<'heap>) -> ParameterIndex { - self.get_or_insert(Parameter::Input(name)) + pub(crate) fn input(&mut self, name: Symbol<'heap>) -> Parameter { + self.get_or_insert(ParameterValue::Input(name)) } /// Allocates a parameter for a symbol used as a JSON object key in SQL expressions. - pub(crate) fn symbol(&mut self, name: Symbol<'heap>) -> ParameterIndex { - self.get_or_insert(Parameter::Symbol(name)) + pub(crate) fn symbol(&mut self, name: Symbol<'heap>) -> Parameter { + self.get_or_insert(ParameterValue::Symbol(name)) } - pub(crate) fn int(&mut self, value: Int) -> ParameterIndex { - self.get_or_insert(Parameter::Int(value)) + pub(crate) fn int(&mut self, value: Int) -> Parameter { + self.get_or_insert(ParameterValue::Int(value)) } - pub(crate) fn primitive(&mut self, primitive: Primitive<'heap>) -> ParameterIndex { - self.get_or_insert(Parameter::Primitive(primitive)) + pub(crate) fn primitive(&mut self, primitive: Primitive<'heap>) -> Parameter { + self.get_or_insert(ParameterValue::Primitive(primitive)) } - pub(crate) fn env(&mut self, body: DefId, field: FieldIndex) -> ParameterIndex { - self.get_or_insert(Parameter::Env(body, field)) + pub(crate) fn env(&mut self, local: Local, field: FieldIndex) -> Parameter { + self.get_or_insert(ParameterValue::Env(local, field)) } - pub(crate) fn temporal_axis(&mut self, axis: TemporalAxis) -> ParameterIndex { - self.get_or_insert(Parameter::TemporalAxis(axis)) + pub(crate) fn temporal_axis(&mut self, axis: TemporalAxis) -> Parameter { + self.get_or_insert(ParameterValue::TemporalAxis(axis)) } /// Returns the number of distinct parameters allocated so far. @@ -156,6 +215,24 @@ impl<'heap, A: Allocator> Parameters<'heap, A> { pub fn is_empty(&self) -> bool { self.reverse.is_empty() } + + pub fn iter( + &self, + ) -> impl ExactSizeIterator> + DoubleEndedIterator { + self.reverse.iter() + } +} + +impl<'this, 'heap> IntoIterator for &'this Parameters<'heap> { + type Item = &'this ParameterValue<'heap>; + + type IntoIter = + impl ExactSizeIterator> + DoubleEndedIterator; + + #[inline] + fn into_iter(self) -> Self::IntoIter { + self.iter() + } } impl fmt::Display for Parameters<'_, A> { @@ -181,7 +258,10 @@ mod tests { id::Id as _, value::{Primitive, String}, }; - use hashql_mir::{body::place::FieldIndex, def::DefId, interpret::value::Int}; + use hashql_mir::{ + body::{local::Local, place::FieldIndex}, + interpret::value::Int, + }; use super::{Parameters, TemporalAxis}; @@ -247,8 +327,8 @@ mod tests { #[test] fn env_dedup() { let mut params = Parameters::new_in(Global); - let a = params.env(DefId::MIN, FieldIndex::new(0)); - let b = params.env(DefId::MIN, FieldIndex::new(0)); + let a = params.env(Local::MIN, FieldIndex::new(0)); + let b = params.env(Local::MIN, FieldIndex::new(0)); assert_eq!(a, b); assert_eq!(params.len(), 1); diff --git a/libs/@local/hashql/eval/src/postgres/projections.rs b/libs/@local/hashql/eval/src/postgres/projections.rs index aa694d81fc2..670c6066d07 100644 --- a/libs/@local/hashql/eval/src/postgres/projections.rs +++ b/libs/@local/hashql/eval/src/postgres/projections.rs @@ -6,7 +6,8 @@ use core::alloc::Allocator; use hash_graph_postgres_store::store::postgres::query::{ self, Alias, Column, ColumnName, ColumnReference, ForeignKeyReference, FromItem, Identifier, - JoinType, SelectExpression, SelectStatement, Table, TableName, TableReference, table, + JoinType, PostgresType, SelectExpression, SelectStatement, Table, TableName, TableReference, + table, }; use hashql_core::symbol::sym; @@ -273,7 +274,8 @@ impl Projections { query::Expression::ColumnReference(ColumnReference { correlation: Some(eit_ref), name: Column::EntityIsOfTypeIds(table::EntityIsOfTypeIds::Versions).into(), - }), + }) + .cast(PostgresType::Array(Box::new(PostgresType::Text))), ]), with_ordinality: false, alias: Some(TableReference { @@ -309,14 +311,14 @@ impl Projections { expression: query::Expression::Function(query::Function::JsonAgg(Box::new( query::Expression::Function(query::Function::JsonBuildObject(vec![ ( - parameters.symbol(sym::base_url).into(), + parameters.symbol(sym::base_url).to_expr(), query::Expression::ColumnReference(ColumnReference { correlation: None, name: ColumnName::from(Identifier::from("b")), }), ), ( - parameters.symbol(sym::version).into(), + parameters.symbol(sym::version).to_expr(), query::Expression::ColumnReference(ColumnReference { correlation: None, name: ColumnName::from(Identifier::from("v")), diff --git a/libs/@local/hashql/eval/src/postgres/traverse.rs b/libs/@local/hashql/eval/src/postgres/traverse.rs index 460b0af6235..0d201729af1 100644 --- a/libs/@local/hashql/eval/src/postgres/traverse.rs +++ b/libs/@local/hashql/eval/src/postgres/traverse.rs @@ -7,13 +7,60 @@ use core::alloc::Allocator; use hash_graph_postgres_store::store::postgres::query::{ - self, Column, ColumnReference, Expression, table, + self, Column, ColumnReference, Constant, Expression, table, }; use hashql_core::symbol::sym; use hashql_mir::pass::execution::traversal::EntityPath; use super::DatabaseContext; +/// Decomposes a `tstzrange` into a `LeftClosedTemporalInterval` JSONB representation. +/// +/// A `LeftClosedTemporalInterval` has: +/// - `start`: always `InclusiveTemporalBound` (just the epoch-ms integer) +/// - `end`: `ExclusiveTemporalBound` (epoch-ms integer) or `UnboundedTemporalBound` (`null`) +/// +/// Produces: +/// ```sql +/// jsonb_build_object( +/// 'start', (extract(epoch from lower(range)) * 1000)::int8, +/// 'end', CASE WHEN upper_inf(range) THEN NULL +/// ELSE (extract(epoch from upper(range)) * 1000)::int8 +/// END +/// ) +/// ``` +/// +/// The epoch values are milliseconds since Unix epoch, matching the HashQL +/// `Timestamp` representation. The start bound needs no conditional because +/// `LeftClosedTemporalInterval` guarantees it is always inclusive. The end +/// bound uses `upper_inf` to distinguish `ExclusiveTemporalBound` (finite) +/// from `UnboundedTemporalBound` (infinite). +fn eval_tstzrange_as_left_closed_interval( + db: &mut DatabaseContext<'_, A>, + range: Expression, +) -> Expression { + let lower = Expression::Function(query::Function::Lower(Box::new(range.clone()))); + let upper = Expression::Function(query::Function::Upper(Box::new(range.clone()))); + let upper_inf = Expression::Function(query::Function::UpperInf(Box::new(range))); + + let start_ms = Expression::Function(query::Function::ExtractEpochMs(Box::new(lower))); + + // end: NULL for unbounded, epoch-ms for exclusive + let upper_ms = Expression::Function(query::Function::ExtractEpochMs(Box::new(upper))); + let end_bound = Expression::CaseWhen { + conditions: vec![(upper_inf, Expression::Constant(Constant::Null))], + else_result: Some(Box::new(upper_ms)), + }; + + let start_key = db.parameters.symbol(sym::start).to_expr(); + let end_key = db.parameters.symbol(sym::end).to_expr(); + + Expression::Function(query::Function::JsonBuildObject(vec![ + (start_key, start_ms), + (end_key, end_bound), + ])) +} + /// Lowers an [`EntityPath`] to a SQL [`Expression`], requesting joins and allocating parameters /// as needed. /// @@ -35,25 +82,25 @@ pub(crate) fn eval_entity_path( ), EntityPath::RecordId => Expression::Function(query::Function::JsonBuildObject(vec![ ( - db.parameters.symbol(sym::entity_id).into(), + db.parameters.symbol(sym::entity_id).to_expr(), eval_entity_path(db, EntityPath::EntityId), ), ( - db.parameters.symbol(sym::draft_id).into(), - eval_entity_path(db, EntityPath::DraftId), + db.parameters.symbol(sym::edition_id).to_expr(), + eval_entity_path(db, EntityPath::EditionId), ), ])), EntityPath::EntityId => Expression::Function(query::Function::JsonBuildObject(vec![ ( - db.parameters.symbol(sym::web_id).into(), + db.parameters.symbol(sym::web_id).to_expr(), eval_entity_path(db, EntityPath::WebId), ), ( - db.parameters.symbol(sym::entity_uuid).into(), + db.parameters.symbol(sym::entity_uuid).to_expr(), eval_entity_path(db, EntityPath::EntityUuid), ), ( - db.parameters.symbol(sym::draft_id).into(), + db.parameters.symbol(sym::draft_id).to_expr(), eval_entity_path(db, EntityPath::DraftId), ), ])), @@ -76,25 +123,35 @@ pub(crate) fn eval_entity_path( EntityPath::TemporalVersioning => { Expression::Function(query::Function::JsonBuildObject(vec![ ( - db.parameters.symbol(sym::decision_time).into(), + db.parameters.symbol(sym::decision_time).to_expr(), eval_entity_path(db, EntityPath::DecisionTime), ), ( - db.parameters.symbol(sym::transaction_time).into(), + db.parameters.symbol(sym::transaction_time).to_expr(), eval_entity_path(db, EntityPath::TransactionTime), ), ])) } - EntityPath::DecisionTime => Expression::ColumnReference(ColumnReference { - correlation: Some(db.projections.temporal_metadata()), - name: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::DecisionTime) - .into(), - }), - EntityPath::TransactionTime => Expression::ColumnReference(ColumnReference { - correlation: Some(db.projections.temporal_metadata()), - name: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::TransactionTime) + EntityPath::DecisionTime => { + let range = Expression::ColumnReference(ColumnReference { + correlation: Some(db.projections.temporal_metadata()), + name: Column::EntityTemporalMetadata(table::EntityTemporalMetadata::DecisionTime) + .into(), + }); + + eval_tstzrange_as_left_closed_interval(db, range) + } + EntityPath::TransactionTime => { + let range = Expression::ColumnReference(ColumnReference { + correlation: Some(db.projections.temporal_metadata()), + name: Column::EntityTemporalMetadata( + table::EntityTemporalMetadata::TransactionTime, + ) .into(), - }), + }); + + eval_tstzrange_as_left_closed_interval(db, range) + } EntityPath::EntityTypeIds => Expression::ColumnReference(db.projections.entity_type_ids()), EntityPath::Archived => Expression::ColumnReference(ColumnReference { correlation: Some(db.projections.entity_editions()), diff --git a/libs/@local/hashql/eval/src/postgres/types.rs b/libs/@local/hashql/eval/src/postgres/types.rs new file mode 100644 index 00000000000..e1dbdf91d99 --- /dev/null +++ b/libs/@local/hashql/eval/src/postgres/types.rs @@ -0,0 +1,118 @@ +use core::ops::ControlFlow; + +use hashql_core::{ + debug_panic, + symbol::Symbol, + r#type::{ + TypeId, + environment::Environment, + kind::{Apply, Generic, OpaqueType, TypeKind}, + }, +}; + +/// Recursively navigates a type structure following a sequence of struct field names. +/// +/// Returns `Continue(Some(id))` when the path resolves to a concrete type, +/// `Continue(None)` when the current branch has no match (e.g. a union variant +/// without the field), or `Break(())` when union variants disagree on the resolved type. +fn traverse_struct_impl( + env: &Environment<'_>, + vertex: TypeId, + fields: &[Symbol<'_>], + depth: usize, +) -> ControlFlow<(), Option> { + let r#type = env.r#type(vertex); + + // We don't need a sophisticated cycle detection algorithm here, the only reason a cycle could + // occur here is if apply and generic substitutions are the only members in a cycle, haven't + // been resolved and simplified away. Which should've created a type error earlier anyway. + if depth > 32 { + debug_panic!("maximum opaque type recursion depth exceeded"); + + return ControlFlow::Continue(None); + } + + match r#type.kind { + &TypeKind::Generic(Generic { base, arguments: _ }) + | &TypeKind::Apply(Apply { + base, + substitutions: _, + }) => traverse_struct_impl(env, base, fields, depth + 1), + TypeKind::Union(union_type) => { + let mut value = None; + + for &variant in union_type.variants { + let variant_value = traverse_struct_impl(env, variant, fields, depth + 1)?; + + match (value, variant_value) { + (None, _) => value = variant_value, + (Some(existing), Some(variant)) => { + if existing != variant { + debug_panic!( + "union variant mismatch: existing={:?} variant={:?}", + existing, + variant + ); + + return ControlFlow::Break(()); + } + } + (Some(_), None) => {} + } + } + + ControlFlow::Continue(value) + } + + TypeKind::Struct(r#struct) => { + if let [name, rest @ ..] = fields { + let field = r#struct.fields.iter().find(|field| field.name == *name); + + field.map_or(ControlFlow::Continue(None), |field| { + traverse_struct_impl(env, field.value, rest, depth + 1) + }) + } else { + // field is empty + ControlFlow::Continue(Some(vertex)) + } + } + + &TypeKind::Opaque(OpaqueType { + name: _, + repr: base, + }) if !fields.is_empty() => traverse_struct_impl(env, base, fields, depth + 1), + + // We cannot traverse into intersection types, because we don't know which variant to + // choose. + TypeKind::Opaque(_) + | TypeKind::Intersection(_) + | TypeKind::Primitive(_) + | TypeKind::Intrinsic(_) + | TypeKind::Tuple(_) + | TypeKind::Closure(_) + | TypeKind::Param(_) + | TypeKind::Infer(_) + | TypeKind::Never + | TypeKind::Unknown => ControlFlow::Continue(fields.is_empty().then_some(vertex)), + } +} + +/// Resolves a sequence of struct field names within a type, returning the final field's +/// [`TypeId`]. +/// +/// For unions, all variants must agree on the resolved type — returns [`None`] if they +/// disagree. When `fields` is empty, returns the type as-is (preserving opaque wrappers). +pub(crate) fn traverse_struct( + env: &Environment<'_>, + vertex: TypeId, + fields: &[Symbol<'_>], +) -> Option { + match traverse_struct_impl(env, vertex, fields, 0) { + ControlFlow::Continue(value) => value, + ControlFlow::Break(()) => { + debug_panic!("traverse_struct_impl broke without a value"); + + None + } + } +} diff --git a/libs/@local/hashql/eval/tests/orchestrator/directives.rs b/libs/@local/hashql/eval/tests/orchestrator/directives.rs new file mode 100644 index 00000000000..50a660247eb --- /dev/null +++ b/libs/@local/hashql/eval/tests/orchestrator/directives.rs @@ -0,0 +1,136 @@ +/// Parsed temporal interval from an `//@ axis` directive. +#[derive(Debug, Clone)] +pub(crate) struct AxisInterval { + pub start: AxisBound, + pub end: AxisBound, +} + +/// A single bound in an axis interval. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AxisBound { + Unbounded, + Included(i128), + Excluded(i128), +} + +/// Parsed axis directives from a test file. +#[derive(Debug, Default)] +pub(crate) struct AxisDirectives { + pub decision: Option, + pub transaction: Option, +} + +/// Parses `//@ axis[decision]` and `//@ axis[transaction]` directives from +/// the source text. +/// +/// Supported interval syntax: +/// - `(T)` : point interval (pinned) +/// - `[a, b)` / `(a, b]` / `[a, b]` / `(a, b)` : range with bounds +/// - `(, b]` / `(, b)` : unbounded start +/// - `[a,)` / `(a,)` : unbounded end +pub(crate) fn parse_directives(source: &str) -> AxisDirectives { + let mut directives = AxisDirectives::default(); + + for line in source.lines() { + let trimmed = line.trim(); + + let Some(rest) = trimmed.strip_prefix("//@") else { + // Stop scanning once we hit a non-directive, non-comment line. + if !trimmed.is_empty() && !trimmed.starts_with("//") { + break; + } + continue; + }; + + let rest = rest.trim(); + + let Some(rest) = rest.strip_prefix("axis[") else { + continue; + }; + + let (axis_name, rest) = rest + .split_once(']') + .expect("malformed axis directive: missing ]"); + + let rest = rest.trim(); + let rest = rest + .strip_prefix('=') + .expect("malformed axis directive: missing ="); + let rest = rest.trim(); + + let interval = parse_interval(rest); + + match axis_name { + "decision" => directives.decision = Some(interval), + "transaction" => directives.transaction = Some(interval), + other => panic!("unknown axis name: {other}"), + } + } + + directives +} + +/// Parses an interval expression like `(T)`, `[a, b)`, `(, b]`, etc. +fn parse_interval(input: &str) -> AxisInterval { + let input = input.trim(); + + let first = input.bytes().next().expect("empty interval expression"); + let start_inclusive = match first { + b'[' => true, + b'(' => false, + other => panic!("unexpected interval start: {}", other as char), + }; + + let last = input + .bytes() + .next_back() + .expect("empty interval expression"); + let end_inclusive = match last { + b']' => true, + b')' => false, + other => panic!("unexpected interval end: {}", other as char), + }; + + // Safe to slice at 1 and len-1: brackets are single-byte ASCII. + let inner = input.get(1..input.len() - 1).expect("interval too short"); + + // Point interval: (T) — bracket style is irrelevant, always [T, T]. + if !inner.contains(',') { + let timestamp = inner + .trim() + .parse::() + .expect("could not parse point interval timestamp"); + + return AxisInterval { + start: AxisBound::Included(timestamp), + end: AxisBound::Included(timestamp), + }; + } + + // Range interval: split on comma. + let (start_str, end_str) = inner + .split_once(',') + .expect("interval must contain a comma"); + + let start = parse_bound(start_str.trim(), start_inclusive); + let end = parse_bound(end_str.trim(), end_inclusive); + + AxisInterval { start, end } +} + +/// Parses a single bound value. Empty string means unbounded. +fn parse_bound(value: &str, inclusive: bool) -> AxisBound { + if value.is_empty() { + return AxisBound::Unbounded; + } + + let timestamp = value + .parse::() + .unwrap_or_else(|error| panic!("could not parse bound {value:?}: {error}")); + + if inclusive { + AxisBound::Included(timestamp) + } else { + AxisBound::Excluded(timestamp) + } +} diff --git a/libs/@local/hashql/eval/tests/orchestrator/discover.rs b/libs/@local/hashql/eval/tests/orchestrator/discover.rs new file mode 100644 index 00000000000..4bafc4962aa --- /dev/null +++ b/libs/@local/hashql/eval/tests/orchestrator/discover.rs @@ -0,0 +1,109 @@ +use std::path::{Path, PathBuf}; + +use hashql_compiletest::pipeline::Pipeline; +use hashql_mir::{ + body::Body, + def::{DefId, DefIdVec}, + intern::Interner, +}; + +/// Signature for programmatic test builders. +/// +/// Each builder receives the pipeline (providing the heap and the shared type +/// environment) and returns the MIR components needed for execution: an +/// interner, the entry definition, and the body set. Inputs are constructed +/// by the test runner from seeded entity data, not by the builder. +pub(crate) type ProgrammaticBuilder = + for<'heap> fn(&Pipeline<'heap>) -> (Interner<'heap>, DefId, DefIdVec>); + +/// A discovered test case, either from a `.jsonc` file or a programmatic +/// registration. +pub(crate) struct TestCase { + /// Display name used by libtest-mimic (and nextest filtering). + pub name: String, + /// Source of the test. + pub source: TestSource, + /// Path to the expected output file (`.stdout`). + pub expected_output: PathBuf, +} + +pub(crate) enum TestSource { + /// Full-pipeline test from a J-Expr file. + JExpr { path: PathBuf }, + /// Programmatic test with a MIR builder function. + Programmatic { builder: ProgrammaticBuilder }, +} + +/// Scans `base_dir/jsonc/` for `.jsonc` files and returns a `TestCase` for +/// each one. The test name is derived from the file stem. +pub(crate) fn discover_jexpr_tests(base_dir: &Path) -> Vec { + let jsonc_dir = base_dir.join("jsonc"); + + if !jsonc_dir.is_dir() { + return Vec::new(); + } + + let mut entries: Vec<_> = std::fs::read_dir(&jsonc_dir) + .expect("could not read jsonc test directory") + .filter_map(|entry| { + let entry = entry.expect("could not read directory entry"); + let path = entry.path(); + + path.extension() + .is_some_and(|ext| ext == "jsonc") + .then_some(path) + }) + .collect(); + + entries.sort(); + + entries + .into_iter() + .map(|path| { + let name = path + .file_stem() + .expect("jsonc file has no stem") + .to_str() + .expect("non-UTF-8 file name") + .to_owned(); + + let expected_output = path.with_extension("stdout"); + + TestCase { + name: format!("jsonc::{name}"), + source: TestSource::JExpr { path }, + expected_output, + } + }) + .collect() +} + +/// Registers programmatic tests from a list of `(name, builder)` pairs. +/// The expected output files live in `base_dir/programmatic/.stdout`. +pub(crate) fn discover_programmatic_tests( + base_dir: &Path, + registry: &[(&str, ProgrammaticBuilder)], +) -> Vec { + let programmatic_dir = base_dir.join("programmatic"); + + registry + .iter() + .map(|&(name, builder)| { + let expected_output = programmatic_dir.join(format!("{name}.stdout")); + + TestCase { + name: format!("programmatic::{name}"), + source: TestSource::Programmatic { builder }, + expected_output, + } + }) + .collect() +} + +/// Returns the base directory for orchestrator UI tests. +pub(crate) fn test_ui_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("ui") + .join("orchestrator") +} diff --git a/libs/@local/hashql/eval/tests/orchestrator/error.rs b/libs/@local/hashql/eval/tests/orchestrator/error.rs new file mode 100644 index 00000000000..66967795993 --- /dev/null +++ b/libs/@local/hashql/eval/tests/orchestrator/error.rs @@ -0,0 +1,46 @@ +use core::{error::Error, fmt}; + +/// Errors during test infrastructure setup: starting the container, +/// connecting to the database, running migrations, or seeding data. +#[derive(Debug)] +pub(crate) enum SetupError { + Container, + Connection, + Migration, + Seed, +} + +impl fmt::Display for SetupError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Container => f.write_str("failed to start test container"), + Self::Connection => f.write_str("failed to connect to database"), + Self::Migration => f.write_str("failed to run database migrations"), + Self::Seed => f.write_str("failed to seed test data"), + } + } +} + +impl Error for SetupError {} + +/// Errors during individual test execution. +#[derive(Debug)] +pub(crate) enum TestError { + ReadSource, + Execution, + Serialization, + OutputMismatch, +} + +impl fmt::Display for TestError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ReadSource => f.write_str("failed to read test source file"), + Self::Execution => f.write_str("query execution failed"), + Self::Serialization => f.write_str("failed to serialize result value"), + Self::OutputMismatch => f.write_str("output comparison failed"), + } + } +} + +impl Error for TestError {} diff --git a/libs/@local/hashql/eval/tests/orchestrator/execution.rs b/libs/@local/hashql/eval/tests/orchestrator/execution.rs new file mode 100644 index 00000000000..c26eff6549a --- /dev/null +++ b/libs/@local/hashql/eval/tests/orchestrator/execution.rs @@ -0,0 +1,152 @@ +use alloc::alloc::Global; +use core::mem; + +use hashql_compiletest::pipeline::Pipeline; +use hashql_core::{heap::ResetAllocator as _, span::SpanId}; +use hashql_diagnostics::{Diagnostic, diagnostic::BoxedDiagnostic}; +use hashql_eval::{ + context::EvalContext, + orchestrator::{AppendEventLog, Event, Orchestrator}, + postgres::PostgresCompiler, +}; +use hashql_mir::{ + body::Body, + def::{DefId, DefIdSlice, DefIdVec}, + intern::Interner, + interpret::{Inputs, value::Value}, +}; +use tokio::runtime; +use tokio_postgres::Client; + +/// Intermediate state after parsing and lowering a J-Expr query. +/// +/// Holds the MIR artifacts needed to build typed inputs (via the decoder +/// and the environment) before proceeding to execution. +pub(crate) struct Lowered<'heap> { + pub interner: Interner<'heap>, + pub entry: DefId, + pub bodies: DefIdVec>, +} + +/// Parses and lowers J-Expr source, returning MIR artifacts. +/// +/// After this call the pipeline's environment contains all types referenced +/// by the query, so the caller can use the decoder to construct typed input +/// values before calling [`run`]. +/// +/// # Errors +/// +/// Returns a diagnostic on parse or lowering failure. +pub(crate) fn lower<'heap>( + pipeline: &mut Pipeline<'heap>, + bytes: impl AsRef<[u8]>, +) -> Result, BoxedDiagnostic<'static, SpanId>> { + let ast = pipeline.parse(bytes)?; + let (interner, entry, bodies) = pipeline.lower(ast)?; + + Ok(Lowered { + interner, + entry, + bodies, + }) +} + +/// Transforms, analyzes, and executes a lowered query. +/// +/// The caller provides pre-built inputs (constructed after lowering so that +/// the type environment is available for decoding). +/// +/// # Errors +/// +/// Returns a diagnostic on transform, analysis, or execution failure. +pub(crate) fn run<'heap>( + pipeline: &mut Pipeline<'heap>, + + runtime: &runtime::Runtime, + client: &Client, + + inputs: &Inputs<'heap, Global>, + + lowered: &mut Lowered<'heap>, +) -> Result<(Value<'heap, Global>, Vec), BoxedDiagnostic<'static, SpanId>> { + run_impl( + pipeline, + runtime, + client, + inputs, + &lowered.interner, + lowered.entry, + &mut lowered.bodies, + ) +} + +/// Executes a pre-built MIR program. +/// +/// Used by programmatic tests that construct bodies directly via the `body!` +/// macro instead of parsing J-Expr source. +/// +/// # Errors +/// +/// Returns a diagnostic on transform, analysis, or execution failure. +pub(crate) fn execute<'heap>( + pipeline: &mut Pipeline<'heap>, + + runtime: &runtime::Runtime, + client: &Client, + + inputs: &Inputs<'heap, Global>, + + interner: &Interner<'heap>, + entry: DefId, + bodies: &mut DefIdSlice>, +) -> Result<(Value<'heap, Global>, Vec), BoxedDiagnostic<'static, SpanId>> { + run_impl(pipeline, runtime, client, inputs, interner, entry, bodies) +} + +struct PostgresClient<'client>(&'client Client); +impl AsRef for PostgresClient<'_> { + fn as_ref(&self) -> &Client { + self.0 + } +} + +fn run_impl<'heap>( + pipeline: &mut Pipeline<'heap>, + + runtime: &runtime::Runtime, + client: &Client, + + inputs: &Inputs<'heap, Global>, + + interner: &Interner<'heap>, + entry: DefId, + bodies: &mut DefIdSlice>, +) -> Result<(Value<'heap, Global>, Vec), BoxedDiagnostic<'static, SpanId>> { + pipeline.transform(interner, bodies)?; + let analysis = pipeline.prepare(interner, bodies)?; + + let mut context = EvalContext::new_in( + &pipeline.env, + interner, + bodies, + &analysis, + pipeline.heap, + &mut pipeline.scratch, + ); + let mut postgres = PostgresCompiler::new_in(&mut context, &mut pipeline.scratch); + let queries = postgres.compile(); + pipeline.scratch.reset(); + + let diagnostics = mem::take(&mut context.diagnostics); + pipeline.diagnostics.append(&mut diagnostics.boxed()); + + let event_log = AppendEventLog::new(); + let orchestrator = + Orchestrator::new(PostgresClient(client), &queries, &context).with_event_log(&event_log); + + let value = runtime + .block_on(orchestrator.run(inputs, entry, [])) + .map_err(Diagnostic::boxed)?; + + Ok((value, event_log.take())) +} diff --git a/libs/@local/hashql/eval/tests/orchestrator/inputs.rs b/libs/@local/hashql/eval/tests/orchestrator/inputs.rs new file mode 100644 index 00000000000..d2276c909c8 --- /dev/null +++ b/libs/@local/hashql/eval/tests/orchestrator/inputs.rs @@ -0,0 +1,282 @@ +use alloc::alloc::Global; + +use hashql_compiletest::pipeline::Pipeline; +use hashql_core::{ + heap::Heap, + module::std_lib::graph::types::{ + knowledge::entity, principal::actor_group::web::types as web_types, + }, + symbol::sym, + r#type::TypeBuilder, +}; +use hashql_eval::orchestrator::codec::{Decoder, JsonValueRef}; +use hashql_mir::{ + intern::Interner, + interpret::{ + Inputs, + value::{self, Value}, + }, +}; +use type_system::knowledge::entity::id::EntityUuid; + +use crate::{ + directives::{AxisBound, AxisDirectives, AxisInterval}, + seed::SeededEntities, +}; + +/// Constructs `Opaque(Timestamp, Integer(ms))`. +fn timestamp_value(ms: i128) -> Value<'static, Global> { + Value::Opaque(value::Opaque::new( + sym::path::Timestamp, + Value::Integer(value::Int::from(ms)), + )) +} + +/// Constructs `Opaque(UnboundedTemporalBound, Unit)`. +fn unbounded_bound() -> Value<'static, Global> { + Value::Opaque(value::Opaque::new( + sym::path::UnboundedTemporalBound, + Value::Unit, + )) +} + +/// Constructs `Opaque(ExclusiveTemporalBound, Timestamp(ms))`. +fn exclusive_bound(ms: i128) -> Value<'static, Global> { + Value::Opaque(value::Opaque::new( + sym::path::ExclusiveTemporalBound, + timestamp_value(ms), + )) +} + +/// Constructs `Opaque(Interval, {end: .., start: ..})`. +/// +/// Fields are sorted lexicographically (`end` before `start`). +fn interval_value<'heap>( + interner: &Interner<'heap>, + start: Value<'heap, Global>, + end: Value<'heap, Global>, +) -> Value<'heap, Global> { + // Fields sorted: "end" < "start" + let fields = interner.symbols.intern_slice(&[sym::end, sym::start]); + let values = vec![end, start]; + + Value::Opaque(value::Opaque::new( + sym::path::Interval, + Value::Struct(value::Struct::new(fields, values).expect("interval struct is valid")), + )) +} + +/// Converts an [`AxisInterval`] to a `Value` representing a temporal +/// interval: `Opaque(Interval, {start: , end: })`. +fn axis_interval_to_value<'heap>( + interner: &Interner<'heap>, + interval: &AxisInterval, +) -> Value<'heap, Global> { + let start = match interval.start { + AxisBound::Unbounded => unbounded_bound(), + AxisBound::Included(ms) => Value::Opaque(value::Opaque::new( + sym::path::InclusiveTemporalBound, + timestamp_value(ms), + )), + AxisBound::Excluded(ms) => exclusive_bound(ms), + }; + let end = match interval.end { + AxisBound::Unbounded => unbounded_bound(), + AxisBound::Included(ms) => Value::Opaque(value::Opaque::new( + sym::path::InclusiveTemporalBound, + timestamp_value(ms), + )), + AxisBound::Excluded(ms) => exclusive_bound(ms), + }; + interval_value(interner, start, end) +} + +/// Returns `true` if the interval is a point (both bounds are Included with +/// the same value). +fn is_point(interval: &AxisInterval) -> Option { + match (&interval.start, &interval.end) { + (AxisBound::Included(start), AxisBound::Included(end)) if start == end => Some(*start), + _ => None, + } +} + +/// Builds temporal axes from parsed directives. +/// +/// `QueryTemporalAxes` is a union of `PinnedTransactionTimeTemporalAxes` and +/// `PinnedDecisionTimeTemporalAxes`. Each has a `pinned` field (single +/// timestamp) and a `variable` field (range interval). The directive system +/// determines which axis is pinned (a point `(T)`) and which is variable +/// (a range `[a, b)` or defaulting to unbounded). +fn temporal_axes_from_directives<'heap>( + interner: &Interner<'heap>, + directives: &AxisDirectives, +) -> Value<'heap, Global> { + let far_future_ms: i128 = 4_102_444_800_000; // 2100-01-01T00:00:00Z + let default_variable = || AxisInterval { + start: AxisBound::Unbounded, + end: AxisBound::Excluded(far_future_ms), + }; + + // Determine which axis is pinned and which is variable. + // Default: pin transaction time, variable decision time. + let (pinned_axis, pinned_ms, variable_axis_name, variable_interval) = + match (&directives.decision, &directives.transaction) { + (None, None) => ( + sym::path::TransactionTime, + far_future_ms, + sym::path::DecisionTime, + default_variable(), + ), + (Some(decision), None) => { + let ms = + is_point(decision).expect("pinned decision axis must be a point interval (T)"); + ( + sym::path::DecisionTime, + ms, + sym::path::TransactionTime, + default_variable(), + ) + } + (None, Some(transaction)) => { + let ms = is_point(transaction) + .expect("pinned transaction axis must be a point interval (T)"); + ( + sym::path::TransactionTime, + ms, + sym::path::DecisionTime, + default_variable(), + ) + } + (Some(decision), Some(transaction)) => { + // One must be a point (pinned), the other a range (variable). + match (is_point(transaction), is_point(decision)) { + (Some(ms), _) => ( + sym::path::TransactionTime, + ms, + sym::path::DecisionTime, + decision.clone(), + ), + (_, Some(ms)) => ( + sym::path::DecisionTime, + ms, + sym::path::TransactionTime, + transaction.clone(), + ), + _ => panic!("when both axes are specified, one must be a point interval"), + } + } + }; + + let pinned = Value::Opaque(value::Opaque::new(pinned_axis, timestamp_value(pinned_ms))); + let variable = Value::Opaque(value::Opaque::new( + variable_axis_name, + axis_interval_to_value(interner, &variable_interval), + )); + + // "pinned" < "variable" lexicographically. + let fields = interner.symbols.intern_slice(&[sym::pinned, sym::variable]); + let values = vec![pinned, variable]; + + let wrapper_name = if pinned_axis == sym::path::TransactionTime { + sym::path::PinnedTransactionTimeTemporalAxes + } else { + sym::path::PinnedDecisionTimeTemporalAxes + }; + + Value::Opaque(value::Opaque::new( + wrapper_name, + Value::Struct(value::Struct::new(fields, values).expect("axes struct is valid")), + )) +} + +/// Builds the shared input set from seeded entity data and axis directives. +/// +/// Uses the decoder and the post-lowering type environment to construct +/// properly typed `Value`s for entity UUIDs and entity IDs. The input names +/// match what J-Expr test files reference via `["input", "", ""]`. +pub(crate) fn build_inputs<'heap>( + heap: &'heap Heap, + pipeline: &Pipeline<'heap>, + interner: &Interner<'heap>, + entities: &SeededEntities, + directives: &AxisDirectives, +) -> Inputs<'heap, Global> { + let mut inputs = Inputs::new(); + let decoder = Decoder::new(&pipeline.env, interner, Global); + let ty = TypeBuilder::synthetic(&pipeline.env); + let entity_uuid_type = entity::types::entity_uuid(&ty, None); + let entity_id_type = entity::types::entity_id(&ty, None); + + // Insert an EntityUuid-typed input. + let insert_uuid = |inputs: &mut Inputs<'heap, Global>, name: &str, uuid: &EntityUuid| { + let uuid_str = uuid.to_string(); + let value = decoder + .decode(entity_uuid_type, JsonValueRef::String(&uuid_str)) + .expect("could not decode EntityUuid input"); + + inputs.insert(heap.intern_symbol(name), value); + }; + + // Insert a full EntityId-typed input. + let insert_entity_id = + |inputs: &mut Inputs<'heap, Global>, + name: &str, + id: &type_system::knowledge::entity::EntityId| { + let json = serde_json::json!({ + "web_id": id.web_id.to_string(), + "entity_uuid": id.entity_uuid.to_string(), + "draft_id": id.draft_id.map(|draft| draft.to_string()), + }); + let value = decoder + .decode(entity_id_type, JsonValueRef::from(&json)) + .expect("could not decode EntityId input"); + + inputs.insert(heap.intern_symbol(name), value); + }; + + insert_uuid(&mut inputs, "alice_uuid", &entities.alice.entity_uuid); + insert_uuid(&mut inputs, "bob_uuid", &entities.bob.entity_uuid); + insert_uuid(&mut inputs, "org_uuid", &entities.organization.entity_uuid); + insert_uuid( + &mut inputs, + "friend_link_uuid", + &entities.friend_link.entity_uuid, + ); + insert_uuid( + &mut inputs, + "draft_alice_uuid", + &entities.draft_alice.entity_uuid, + ); + + insert_entity_id(&mut inputs, "alice_id", &entities.alice); + insert_entity_id(&mut inputs, "bob_id", &entities.bob); + insert_entity_id(&mut inputs, "org_id", &entities.organization); + insert_entity_id(&mut inputs, "friend_link_id", &entities.friend_link); + insert_entity_id(&mut inputs, "draft_alice_id", &entities.draft_alice); + + // WebId input (all seeded entities share the same web). + let web_id_type = web_types::web_id(&ty, None); + let web_id_value = decoder + .decode( + web_id_type, + JsonValueRef::String(&entities.alice.web_id.to_string()), + ) + .expect("could not decode WebId input"); + inputs.insert(heap.intern_symbol("web_id"), web_id_value); + + // String inputs for property-based filtering. + let string_type = ty.string(); + let alice_name = decoder + .decode(string_type, JsonValueRef::String("Alice")) + .expect("could not decode string input"); + inputs.insert(heap.intern_symbol("alice_name"), alice_name); + + // Temporal axes from directives (or default: unbounded decision time, + // far-future transaction pin). + inputs.insert( + heap.intern_symbol("temporal_axes"), + temporal_axes_from_directives(interner, directives), + ); + + inputs +} diff --git a/libs/@local/hashql/eval/tests/orchestrator/main.rs b/libs/@local/hashql/eval/tests/orchestrator/main.rs new file mode 100644 index 00000000000..08301562a3c --- /dev/null +++ b/libs/@local/hashql/eval/tests/orchestrator/main.rs @@ -0,0 +1,244 @@ +#![feature(allocator_api)] +extern crate alloc; + +use alloc::sync::Arc; + +use error_stack::{Report, ResultExt as _}; +use hash_graph_postgres_store::store::{AsClient as _, PostgresStore, PostgresStoreSettings}; +use hashql_compiletest::pipeline::Pipeline; +use hashql_core::heap::Heap; +use testcontainers::{ImageExt as _, ReuseDirective, runners::AsyncRunner as _}; +use testcontainers_modules::postgres::Postgres; +use tokio::runtime::{self, Runtime}; +use tokio_postgres::{Client, NoTls}; + +mod directives; +mod discover; +mod error; +mod execution; +mod inputs; +mod output; +mod programmatic; +mod seed; + +use self::{ + directives::{AxisDirectives, parse_directives}, + discover::{ + ProgrammaticBuilder, TestSource, discover_jexpr_tests, discover_programmatic_tests, + test_ui_dir, + }, + error::{SetupError, TestError}, + inputs::build_inputs, + output::{compare_or_bless, render_failure, render_success}, + seed::SeededEntities, +}; + +struct TestContext { + _container: testcontainers::ContainerAsync, + store: Arc>, + entities: SeededEntities, +} + +async fn setup() -> Result> { + let container = Postgres::default() + .with_user("hash") + .with_password("hash") + .with_db_name("hash") + .with_name("pgvector/pgvector") + .with_tag("0.8.2-pg18-trixie") + .with_reuse(ReuseDirective::CurrentSession) + .with_cmd([ + "postgres", + "-c", + "log_statement=all", + "-c", + "log_destination=stderr", + ]) + .start() + .await + .change_context(SetupError::Container)?; + + let host = container + .get_host() + .await + .change_context(SetupError::Container) + .attach("could not resolve container host")? + .to_string(); + let port = container + .get_host_port_ipv4(5432) + .await + .change_context(SetupError::Container) + .attach("could not resolve container port")?; + + let (client, connection) = tokio_postgres::Config::new() + .user("hash") + .password("hash") + .host(&host) + .port(port) + .dbname("hash") + .connect(NoTls) + .await + .change_context(SetupError::Connection)?; + tokio::spawn(connection); + + let mut store = PostgresStore::new(client, None, Arc::new(PostgresStoreSettings::default())); + let entities = seed::setup(&mut store).await?; + + Ok(TestContext { + _container: container, + store: Arc::new(store), + entities, + }) +} + +/// Runs a J-Expr test: parse, lower, build inputs, execute, compare output. +fn run_jexpr_test( + runtime: &Runtime, + context: &TestContext, + path: &std::path::Path, + expected_output: &std::path::Path, + bless: bool, +) -> Result<(), Report> { + let bytes = std::fs::read(path) + .change_context(TestError::ReadSource) + .attach_with(|| format!("{}", path.display()))?; + + let source = String::from_utf8_lossy(&bytes); + let axis_directives = parse_directives(&source); + let heap = Heap::new(); + let mut pipeline = Pipeline::new(&heap); + + // Lower first so the type environment is populated, then build inputs. + let mut lowered = match execution::lower(&mut pipeline, &bytes) { + Ok(lowered) => lowered, + Err(diagnostic) => { + let rendered = render_failure(&source, &pipeline, &diagnostic); + return Err(Report::new(TestError::Execution).attach(rendered)); + } + }; + + let inputs = build_inputs( + &heap, + &pipeline, + &lowered.interner, + &context.entities, + &axis_directives, + ); + + match execution::run( + &mut pipeline, + runtime, + context.store.as_client(), + &inputs, + &mut lowered, + ) { + Ok((value, events)) => { + let rendered = render_success(&source, &value, &events, &pipeline)?; + compare_or_bless(&rendered, expected_output, bless) + } + Err(diagnostic) => { + let rendered = render_failure(&source, &pipeline, &diagnostic); + Err(Report::new(TestError::Execution).attach(rendered)) + } + } +} + +/// Runs a programmatic test: build MIR directly, execute, compare output. +/// +/// Inputs are constructed from seeded entity data using the same +/// [`build_inputs`] helper as J-Expr tests. The programmatic builder +/// only constructs the MIR bodies; it references inputs via +/// `input.load!` statements. +fn run_programmatic_test( + runtime: &Runtime, + context: &TestContext, + builder: ProgrammaticBuilder, + expected_output: &std::path::Path, + bless: bool, +) -> Result<(), Report> { + let heap = Heap::new(); + let mut pipeline = Pipeline::new(&heap); + let (interner, entry, mut bodies) = builder(&pipeline); + + let inputs = build_inputs( + &heap, + &pipeline, + &interner, + &context.entities, + &AxisDirectives::default(), + ); + + // Programmatic tests have no J-Expr source, so diagnostics render + // without source context (all spans are synthetic). + let source = ""; + + match execution::execute( + &mut pipeline, + runtime, + context.store.as_client(), + &inputs, + &interner, + entry, + &mut bodies, + ) { + Ok((value, events)) => { + let rendered = render_success(source, &value, &events, &pipeline)?; + compare_or_bless(&rendered, expected_output, bless) + } + Err(diagnostic) => { + let rendered = render_failure(source, &pipeline, &diagnostic); + Err(Report::new(TestError::Execution).attach(rendered)) + } + } +} + +const PROGRAMMATIC_TESTS: &[(&str, ProgrammaticBuilder)] = &[ + ("property-access", programmatic::property_access), + ("property-arithmetic", programmatic::property_arithmetic), +]; + +fn main() -> Result<(), Report> { + let arguments = libtest_mimic::Arguments::from_args(); + let bless = std::env::args().any(|arg| arg == "--bless") || std::env::var("BLESS").is_ok(); + + let runtime = runtime::Builder::new_multi_thread() + .enable_all() + .build() + .change_context(SetupError::Container) + .attach("could not build tokio runtime")?; + let runtime = Arc::new(runtime); + + let context = runtime.block_on(setup())?; + let context = Arc::new(context); + + let ui_dir = test_ui_dir(); + let mut test_cases = discover_jexpr_tests(&ui_dir); + test_cases.extend(discover_programmatic_tests(&ui_dir, PROGRAMMATIC_TESTS)); + + let trials: Vec<_> = test_cases + .into_iter() + .map(|test_case| { + let context = Arc::clone(&context); + let runtime = Arc::clone(&runtime); + + libtest_mimic::Trial::test(&test_case.name, move || { + let result = match &test_case.source { + TestSource::JExpr { path } => { + run_jexpr_test(&runtime, &context, path, &test_case.expected_output, bless) + } + TestSource::Programmatic { builder } => run_programmatic_test( + &runtime, + &context, + *builder, + &test_case.expected_output, + bless, + ), + }; + + result.map_err(|report| format!("{report:?}").into()) + }) + }) + .collect(); + + libtest_mimic::run(&arguments, trials).exit(); +} diff --git a/libs/@local/hashql/eval/tests/orchestrator/output.rs b/libs/@local/hashql/eval/tests/orchestrator/output.rs new file mode 100644 index 00000000000..0573c093acb --- /dev/null +++ b/libs/@local/hashql/eval/tests/orchestrator/output.rs @@ -0,0 +1,217 @@ +use alloc::alloc::Global; +use std::{collections::HashMap, fs, path::Path, sync::LazyLock}; + +use error_stack::{Report, ResultExt as _}; +use hashql_compiletest::pipeline::Pipeline; +use hashql_core::span::SpanId; +use hashql_diagnostics::{ + Source, Sources, + diagnostic::{ + BoxedDiagnostic, + render::{ColorDepth, Format, RenderOptions}, + }, +}; +use hashql_eval::orchestrator::{Event, codec::Serde}; +use hashql_mir::interpret::value::Value; +use regex::Regex; +use similar_asserts::SimpleDiff; + +use crate::error::TestError; + +/// Renders a single diagnostic to a plain-text string using the pipeline's +/// span table for source resolution. +fn render_diagnostic( + source: &str, + pipeline: &Pipeline<'_>, + diagnostic: &BoxedDiagnostic<'_, SpanId>, +) -> String { + let mut sources = Sources::new(); + sources.push(Source::new(source)); + + let mut options = RenderOptions::new(Format::Ansi, &sources); + options.color_depth = ColorDepth::Monochrome; + + diagnostic.render(options, &mut &pipeline.spans) +} + +/// Renders accumulated warnings from the pipeline into a single string. +/// +/// Returns `None` if there are no warnings. +fn render_warnings(source: &str, pipeline: &Pipeline<'_>) -> Option { + if pipeline.diagnostics.is_empty() { + return None; + } + + let mut output = String::new(); + + for diagnostic in pipeline.diagnostics.iter() { + if !output.is_empty() { + output.push_str("\n\n"); + } + output.push_str(&render_diagnostic(source, pipeline, diagnostic)); + } + + Some(output) +} + +static UUID_RE: LazyLock = LazyLock::new(|| { + Regex::new("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + .expect("UUID regex is valid") +}); + +static TIMESTAMP_RE: LazyLock = LazyLock::new(|| { + Regex::new(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})") + .expect("timestamp regex is valid") +}); + +static EPOCH_MS_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"("(?:start|end)":\s*)\d{13}"#).expect("epoch millis regex is valid") +}); + +/// Replaces UUIDs with positional placeholders (``, ``, ...) +/// and ISO timestamps with ``. +/// +/// The same UUID always maps to the same placeholder within a single output, +/// preserving structural assertions (e.g. two fields referencing the same +/// entity get the same `` tag). +fn normalize(input: &str) -> String { + let mut uuid_map: HashMap = HashMap::new(); + + let after_uuids = UUID_RE.replace_all(input, |caps: ®ex::Captures<'_>| { + let uuid = caps[0].to_owned(); + let count = uuid_map.len(); + let index = *uuid_map.entry(uuid).or_insert(count); + format!("") + }); + + let after_timestamps = TIMESTAMP_RE.replace_all(&after_uuids, ""); + + EPOCH_MS_RE + .replace_all(&after_timestamps, "${1}") + .into_owned() +} + +/// Renders the complete test output: the JSON value followed by any warnings. +/// +/// The format is: +/// ```text +/// +/// --- +/// +/// +/// +/// ``` +/// +/// The `---` separator and warnings section only appear when warnings exist. +/// +/// # Errors +/// +/// Returns [`TestError::Serialization`] if the value cannot be serialized to +/// JSON. +pub(crate) fn render_success( + source: &str, + value: &Value<'_, Global>, + events: &[Event], + pipeline: &Pipeline<'_>, +) -> Result> { + let json = + serde_json::to_string_pretty(&Serde(value)).change_context(TestError::Serialization)?; + + let mut output = normalize(&json); + + if !events.is_empty() { + output.push_str("\n---\n"); + for event in events { + output.push_str(&event.to_string()); + output.push('\n'); + } + } + + if let Some(warnings) = render_warnings(source, pipeline) { + output.push_str("\n---\n"); + output.push_str(&warnings); + } + + Ok(output) +} + +/// Renders a compilation or execution failure as a test error message. +/// +/// Includes the rendered diagnostic and any accumulated warnings from +/// earlier pipeline stages. +pub(crate) fn render_failure( + source: &str, + pipeline: &Pipeline<'_>, + diagnostic: &BoxedDiagnostic<'_, SpanId>, +) -> String { + let mut output = render_diagnostic(source, pipeline, diagnostic); + + if let Some(warnings) = render_warnings(source, pipeline) { + output.push_str("\n\nalso emitted warnings:\n"); + output.push_str(&warnings); + } + + output +} + +/// Compares rendered output against the expected `.stdout` file. +/// +/// If `bless` is true, writes the actual output to the file instead of +/// comparing. +/// +/// # Errors +/// +/// Returns [`TestError::OutputMismatch`] when the actual output differs from +/// the expected content, with the diff attached to the report. +pub(crate) fn compare_or_bless( + actual: &str, + expected_path: &Path, + bless: bool, +) -> Result<(), Report> { + if bless { + if let Some(parent) = expected_path.parent() { + fs::create_dir_all(parent) + .change_context(TestError::OutputMismatch) + .attach_with(|| format!("could not create directory {}", parent.display()))?; + } + + fs::write(expected_path, actual) + .change_context(TestError::OutputMismatch) + .attach_with(|| { + format!( + "could not write blessed output to {}", + expected_path.display() + ) + })?; + + return Ok(()); + } + + let expected = match fs::read_to_string(expected_path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(Report::new(TestError::OutputMismatch) + .attach(format!( + "expected output file {} does not exist; run with --bless to create it", + expected_path.display() + )) + .attach(format!("actual output:\n{actual}"))); + } + Err(error) => { + return Err(Report::new(error) + .change_context(TestError::OutputMismatch) + .attach(format!("could not read {}", expected_path.display()))); + } + }; + + if actual == expected { + return Ok(()); + } + + let diff = SimpleDiff::from_str(&expected, actual, "expected", "actual"); + + Err(Report::new(TestError::OutputMismatch).attach(format!( + "output mismatch for {}\n\n{diff}\n\nrun with --bless to update the expected output", + expected_path.display() + ))) +} diff --git a/libs/@local/hashql/eval/tests/orchestrator/programmatic.rs b/libs/@local/hashql/eval/tests/orchestrator/programmatic.rs new file mode 100644 index 00000000000..0f5f2cc5050 --- /dev/null +++ b/libs/@local/hashql/eval/tests/orchestrator/programmatic.rs @@ -0,0 +1,225 @@ +use hashql_compiletest::pipeline::Pipeline; +use hashql_core::{ + heap, + module::std_lib::graph::types::knowledge::entity, + r#type::{TypeBuilder, TypeId}, +}; +use hashql_hir::node::{HirId, operation::InputOp}; +use hashql_mir::{ + body::{ + Body, Source, + operand::Operand, + terminator::{GraphRead, GraphReadBody, GraphReadHead, GraphReadTail, TerminatorKind}, + }, + builder::BodyBuilder, + def::{DefId, DefIdVec}, + intern::Interner, + op, +}; + +/// 1.2: Filter entities where the "name" property equals the `alice_name` input. +/// +/// Constructs a graph read with a filter body that projects +/// `vertex.properties.` and compares against a string input. +/// This exercises the property hydration path, which requires the MIR builder +/// because property field names are URLs that the HIR cannot resolve. +pub(crate) fn property_access<'heap>( + pipeline: &Pipeline<'heap>, +) -> (Interner<'heap>, DefId, DefIdVec>) { + let heap = pipeline.heap; + let interner = Interner::new(heap); + let ty = TypeBuilder::synthetic(&pipeline.env); + + let unknown_ty = ty.unknown(); + let bool_ty = ty.boolean(); + let unit_ty = ty.tuple([] as [TypeId; 0]); + + // Entity: properties are un-narrowed. + let entity_ty = entity::types::entity(&ty, unknown_ty, None); + + let entry_id = DefId::new(0); + let filter_id = DefId::new(1); + + // Entry body: load temporal_axes, graph read with filter, return result. + let entry_body = { + let mut builder = BodyBuilder::new(&interner); + + let axis = builder.local("axis", unknown_ty); + let env_local = builder.local("env", unit_ty); + let graph_result = builder.local("graph_result", unknown_ty); + + let bb0 = builder.reserve_block([]); + let bb1 = builder.reserve_block([graph_result.local]); + + builder + .build_block(bb0) + .assign_place(axis, |rv| { + rv.input(InputOp::Load { required: true }, "temporal_axes") + }) + .assign_place(env_local, |rv| rv.tuple([] as [Operand<'_>; 0])) + .finish_with_terminator(TerminatorKind::GraphRead(GraphRead { + head: GraphReadHead::Entity { + axis: Operand::Place(axis), + }, + body: { + let mut body = heap::Vec::new_in(heap); + body.push(GraphReadBody::Filter(filter_id, env_local.local)); + body + }, + tail: GraphReadTail::Collect, + target: bb1, + })); + + builder.build_block(bb1).ret(graph_result); + + let mut body = builder.finish(0, unknown_ty); + body.id = entry_id; + body.source = Source::Closure(HirId::PLACEHOLDER, None); + body + }; + + // Filter body: fn(env: (), vertex: Entity) -> Bool + let filter_body = { + let mut builder = BodyBuilder::new(&interner); + + let _env = builder.local("env", unit_ty); + let vertex = builder.local("vertex", entity_ty); + let props = + builder.place(|place| place.from(vertex).field_by_name("properties", unknown_ty)); + let name_value = builder.place(|place| { + place.from(props).field_by_name( + "https://blockprotocol.org/@alice/types/property-type/name/", + unknown_ty, + ) + }); + let alice_name = builder.local("alice_name", unknown_ty); + let result = builder.local("result", bool_ty); + + let bb0 = builder.reserve_block([]); + + builder + .build_block(bb0) + .assign_place(alice_name, |rv| { + rv.input(InputOp::Load { required: true }, "alice_name") + }) + .assign_place(result, |rv| rv.binary(name_value, op![==], alice_name)) + .ret(result); + + let mut body = builder.finish(2, bool_ty); + body.id = filter_id; + body.source = Source::GraphReadFilter(HirId::PLACEHOLDER); + body + }; + + // Entry must be pushed first (DefId 0), filter second (DefId 1). + let mut bodies = DefIdVec::new(); + let id0 = bodies.push(entry_body); + let id1 = bodies.push(filter_body); + debug_assert_eq!(id0, entry_id); + debug_assert_eq!(id1, filter_id); + + (interner, entry_id, bodies) +} + +/// Filter entities where `age + 5 > 30`. +/// +/// Exercises NULL propagation through arithmetic on a missing JSONB key: +/// only Bob has an `age` property (42), so `42 + 5 = 47 > 30` passes. +/// All other entities lack the key, producing NULL that propagates through +/// the addition and comparison, then gets rejected by the COALESCE at the +/// continuation return point. +pub(crate) fn property_arithmetic<'heap>( + pipeline: &Pipeline<'heap>, +) -> (Interner<'heap>, DefId, DefIdVec>) { + let heap = pipeline.heap; + let interner = Interner::new(heap); + let ty = TypeBuilder::synthetic(&pipeline.env); + + let unknown_ty = ty.unknown(); + let bool_ty = ty.boolean(); + let int_ty = ty.integer(); + let unit_ty = ty.tuple([] as [TypeId; 0]); + + let entity_ty = entity::types::entity(&ty, unknown_ty, None); + + let entry_id = DefId::new(0); + let filter_id = DefId::new(1); + + let entry_body = { + let mut builder = BodyBuilder::new(&interner); + + let axis = builder.local("axis", unknown_ty); + let env_local = builder.local("env", unit_ty); + let graph_result = builder.local("graph_result", unknown_ty); + + let bb0 = builder.reserve_block([]); + let bb1 = builder.reserve_block([graph_result.local]); + + builder + .build_block(bb0) + .assign_place(axis, |rv| { + rv.input(InputOp::Load { required: true }, "temporal_axes") + }) + .assign_place(env_local, |rv| rv.tuple([] as [Operand<'_>; 0])) + .finish_with_terminator(TerminatorKind::GraphRead(GraphRead { + head: GraphReadHead::Entity { + axis: Operand::Place(axis), + }, + body: { + let mut body = heap::Vec::new_in(heap); + body.push(GraphReadBody::Filter(filter_id, env_local.local)); + body + }, + tail: GraphReadTail::Collect, + target: bb1, + })); + + builder.build_block(bb1).ret(graph_result); + + let mut body = builder.finish(0, unknown_ty); + body.id = entry_id; + body.source = Source::Closure(HirId::PLACEHOLDER, None); + body + }; + + // Filter body: (vertex.properties. + 5) > 30 + let filter_body = { + let mut builder = BodyBuilder::new(&interner); + + let _env = builder.local("env", unit_ty); + let vertex = builder.local("vertex", entity_ty); + let props = + builder.place(|place| place.from(vertex).field_by_name("properties", unknown_ty)); + let age_value = builder.place(|place| { + place.from(props).field_by_name( + "https://blockprotocol.org/@alice/types/property-type/age/", + unknown_ty, + ) + }); + let sum = builder.local("sum", int_ty); + let result = builder.local("result", bool_ty); + let five = builder.const_int(5); + let thirty = builder.const_int(30); + + let bb0 = builder.reserve_block([]); + + builder + .build_block(bb0) + .assign_place(sum, |rv| rv.binary(age_value, op![+], five)) + .assign_place(result, |rv| rv.binary(sum, op![>], thirty)) + .ret(result); + + let mut body = builder.finish(2, bool_ty); + body.id = filter_id; + body.source = Source::GraphReadFilter(HirId::PLACEHOLDER); + body + }; + + let mut bodies = DefIdVec::new(); + let id0 = bodies.push(entry_body); + let id1 = bodies.push(filter_body); + debug_assert_eq!(id0, entry_id); + debug_assert_eq!(id1, filter_id); + + (interner, entry_id, bodies) +} diff --git a/libs/@local/hashql/eval/tests/orchestrator/seed.rs b/libs/@local/hashql/eval/tests/orchestrator/seed.rs new file mode 100644 index 00000000000..cbffde7137b --- /dev/null +++ b/libs/@local/hashql/eval/tests/orchestrator/seed.rs @@ -0,0 +1,381 @@ +use std::collections::HashMap; + +use error_stack::{Report, ResultExt as _}; +use hash_graph_authorization::policies::store::{PolicyStore as _, PrincipalStore as _}; +use hash_graph_postgres_store::store::{AsClient as _, PostgresStore}; +use hash_graph_store::{ + account::{AccountStore as _, CreateUserActorParams}, + data_type::{CreateDataTypeParams, DataTypeStore as _}, + entity::{CreateEntityParams, EntityStore as _}, + entity_type::{CreateEntityTypeParams, EntityTypeStore as _}, + migration::StoreMigration as _, + property_type::{CreatePropertyTypeParams, PropertyTypeStore as _}, + query::ConflictBehavior, +}; +use hash_graph_test_data::{data_type, entity, entity_type, property_type}; +use tokio_postgres::Client; +use type_system::{ + knowledge::{ + Confidence, + entity::{EntityId, LinkData, provenance::ProvidedEntityEditionProvenance}, + property::{PropertyObject, PropertyObjectWithMetadata, metadata::PropertyProvenance}, + }, + ontology::{ + data_type::DataType, + entity_type::EntityType, + id::VersionedUrl, + property_type::PropertyType, + provenance::{OntologyOwnership, ProvidedOntologyEditionProvenance}, + }, + principal::{ + actor::{ActorEntityUuid, ActorType, MachineId}, + actor_group::WebId, + }, + provenance::{OriginProvenance, OriginType}, +}; + +use crate::error::SetupError; + +/// Entity IDs created during seeding, needed by tests to construct queries +/// and provide inputs. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub(crate) struct SeededEntities { + pub alice: EntityId, + pub bob: EntityId, + pub organization: EntityId, + pub friend_link: EntityId, + pub draft_alice: EntityId, +} + +const SEED_KEY: &str = "orchestrator_test_seed"; + +fn entity_type_id(json: &str) -> VersionedUrl { + serde_json::from_str::(json) + .expect("could not parse entity type") + .id +} + +const fn entity_provenance() -> ProvidedEntityEditionProvenance { + ProvidedEntityEditionProvenance { + actor_type: ActorType::User, + origin: OriginProvenance::from_empty_type(OriginType::Api), + sources: Vec::new(), + } +} + +/// Ensures the state table exists and returns the previously seeded entities +/// if seeding was already completed. +async fn load_existing_seed( + store: &PostgresStore, +) -> Result, Report> { + let client = store.as_client(); + + client + .execute( + "CREATE TABLE IF NOT EXISTS _orchestrator_test_state ( + key TEXT PRIMARY KEY, + value JSONB + )", + &[], + ) + .await + .change_context(SetupError::Seed) + .attach("could not create state table")?; + + let row = client + .query_opt( + "SELECT value FROM _orchestrator_test_state WHERE key = $1", + &[&SEED_KEY], + ) + .await + .change_context(SetupError::Seed) + .attach("could not query seed state")?; + + match row { + Some(row) => { + let value: serde_json::Value = row.get(0); + let entities: SeededEntities = serde_json::from_value(value) + .change_context(SetupError::Seed) + .attach("could not deserialize stored seed state")?; + Ok(Some(entities)) + } + None => Ok(None), + } +} + +async fn save_seed( + store: &PostgresStore, + entities: &SeededEntities, +) -> Result<(), Report> { + let value = serde_json::to_value(entities) + .change_context(SetupError::Seed) + .attach("could not serialize seed state")?; + + store + .as_client() + .execute( + "INSERT INTO _orchestrator_test_state (key, value) VALUES ($1, $2)", + &[&SEED_KEY, &value], + ) + .await + .change_context(SetupError::Seed) + .attach("could not persist seed state")?; + + Ok(()) +} + +/// Connects to the database, runs migrations, and seeds test data if needed. +/// +/// On a reused container where seeding already completed, returns the +/// previously stored entity IDs without creating duplicates. +pub(crate) async fn setup( + store: &mut PostgresStore, +) -> Result> { + store + .run_migrations() + .await + .change_context(SetupError::Migration)?; + store + .seed_system_policies() + .await + .change_context(SetupError::Seed) + .attach("could not seed system policies")?; + + if let Some(entities) = load_existing_seed(store).await? { + return Ok(entities); + } + + let entities = seed_data(store).await?; + save_seed(store, &entities).await?; + + Ok(entities) +} + +/// Seeds all ontology types (data types, property types, entity types). +async fn seed_ontology( + store: &mut PostgresStore, + actor_id: ActorEntityUuid, + ownership: &OntologyOwnership, +) -> Result<(), Report> { + let ontology_provenance = ProvidedOntologyEditionProvenance { + actor_type: ActorType::User, + origin: OriginProvenance::from_empty_type(OriginType::Api), + sources: Vec::new(), + }; + + store + .create_data_types( + actor_id, + [ + data_type::VALUE_V1, + data_type::TEXT_V1, + data_type::NUMBER_V1, + ] + .into_iter() + .map(|json| CreateDataTypeParams { + schema: serde_json::from_str::(json).expect("could not parse data type"), + ownership: ownership.clone(), + conflict_behavior: ConflictBehavior::Skip, + provenance: ontology_provenance.clone(), + conversions: HashMap::new(), + }), + ) + .await + .change_context(SetupError::Seed) + .attach("could not seed data types")?; + + store + .create_property_types( + actor_id, + [ + // Leaf property types (no property type refs). + property_type::NAME_V1, + property_type::AGE_V1, + property_type::FAVORITE_FILM_V1, + property_type::FAVORITE_SONG_V1, + property_type::HOBBY_V1, + // Composite (refs leaf property types above). + property_type::INTERESTS_V1, + ] + .into_iter() + .map(|json| CreatePropertyTypeParams { + schema: serde_json::from_str::(json) + .expect("could not parse property type"), + ownership: ownership.clone(), + conflict_behavior: ConflictBehavior::Skip, + provenance: ontology_provenance.clone(), + }), + ) + .await + .change_context(SetupError::Seed) + .attach("could not seed property types")?; + + store + .create_entity_types( + actor_id, + [ + entity_type::LINK_V1, + entity_type::link::FRIEND_OF_V1, + entity_type::link::ACQUAINTANCE_OF_V1, + entity_type::PERSON_V1, + entity_type::ORGANIZATION_V1, + ] + .into_iter() + .map(|json| CreateEntityTypeParams { + schema: serde_json::from_str::(json) + .expect("could not parse entity type"), + ownership: ownership.clone(), + conflict_behavior: ConflictBehavior::Skip, + provenance: ontology_provenance.clone(), + }), + ) + .await + .change_context(SetupError::Seed) + .attach("could not seed entity types")?; + + Ok(()) +} + +/// Creates a non-link entity from a property JSON fixture and entity type JSON. +async fn create_entity( + store: &mut PostgresStore, + actor_id: ActorEntityUuid, + web_id: WebId, + entity_type_json: &str, + properties_json: &str, + draft: bool, +) -> Result> { + let properties: PropertyObject = + serde_json::from_str(properties_json).expect("could not parse entity properties"); + + let entity = store + .create_entity( + actor_id, + CreateEntityParams { + web_id, + entity_uuid: None, + decision_time: None, + entity_type_ids: std::collections::HashSet::from([entity_type_id( + entity_type_json, + )]), + properties: PropertyObjectWithMetadata::from_parts(properties, None) + .expect("could not create property metadata"), + confidence: None, + link_data: None, + draft, + policies: Vec::new(), + provenance: entity_provenance(), + }, + ) + .await + .change_context(SetupError::Seed)?; + + Ok(entity.metadata.record_id.entity_id) +} + +async fn seed_data( + store: &mut PostgresStore, +) -> Result> { + let system_account_id: MachineId = store + .get_or_create_system_machine("h") + .await + .change_context(SetupError::Seed) + .attach("could not create system machine")?; + let user_id = store + .create_user_actor( + system_account_id.into(), + CreateUserActorParams { + user_id: None, + shortname: Some("orchestrator-test".to_owned()), + registration_complete: true, + }, + ) + .await + .change_context(SetupError::Seed) + .attach("could not create test user")? + .user_id; + + let actor_id: ActorEntityUuid = user_id.into(); + let web_id: WebId = user_id.into(); + let ownership = OntologyOwnership::Local { web_id }; + + seed_ontology(store, actor_id, &ownership).await?; + + let alice = create_entity( + store, + actor_id, + web_id, + entity_type::PERSON_V1, + entity::PERSON_ALICE_V1, + false, + ) + .await?; + + let bob = create_entity( + store, + actor_id, + web_id, + entity_type::PERSON_V1, + entity::PERSON_BOB_V1, + false, + ) + .await?; + + let organization = create_entity( + store, + actor_id, + web_id, + entity_type::ORGANIZATION_V1, + entity::ORGANIZATION_V1, + false, + ) + .await?; + + let draft_alice = create_entity( + store, + actor_id, + web_id, + entity_type::PERSON_V1, + entity::PERSON_ALICE_V1, + true, + ) + .await?; + + let friend_link = store + .create_entity( + actor_id, + CreateEntityParams { + web_id, + entity_uuid: None, + decision_time: None, + entity_type_ids: std::collections::HashSet::from([entity_type_id( + entity_type::link::FRIEND_OF_V1, + )]), + properties: PropertyObjectWithMetadata::from_parts(PropertyObject::empty(), None) + .expect("could not create property metadata"), + confidence: None, + link_data: Some(LinkData { + left_entity_id: alice, + right_entity_id: bob, + left_entity_confidence: Confidence::new(0.9), + left_entity_provenance: PropertyProvenance::default(), + right_entity_confidence: Confidence::new(0.8), + right_entity_provenance: PropertyProvenance::default(), + }), + draft: false, + policies: Vec::new(), + provenance: entity_provenance(), + }, + ) + .await + .change_context(SetupError::Seed) + .attach("could not create friend-of link entity")?; + + Ok(SeededEntities { + alice, + bob, + organization, + friend_link: friend_link.metadata.record_id.entity_id, + draft_alice, + }) +} diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/.spec.toml b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/.spec.toml new file mode 100644 index 00000000000..ef02627aed9 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/.spec.toml @@ -0,0 +1,2 @@ +skip = true +suite = "eval/orchestrator" diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/draft-entity.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/draft-entity.jsonc new file mode 100644 index 00000000000..08646f21f68 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/draft-entity.jsonc @@ -0,0 +1,14 @@ +// Select the draft entity by UUID. Verifies EntityPath::DraftId produces +// Optional::Value (non-null draft_id) rather than Optional::Skipped. +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + ["==", + "vertex.metadata.record_id.entity_id.entity_uuid", + ["input", "draft_alice_uuid", "::graph::types::knowledge::entity::EntityUuid"] + ] + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/draft-entity.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/draft-entity.stdout new file mode 100644 index 00000000000..14e3bc7b908 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/draft-entity.stdout @@ -0,0 +1,11 @@ +[ + {} +] +--- +query executed: body 3, block bb0 +row received +filter started: body 2 +island entered: body 2, island 0, target postgres +continuation implicit true: body 2 +filter accepted: body 2 +row accepted diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-by-entity-id.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-by-entity-id.jsonc new file mode 100644 index 00000000000..437ac66e268 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-by-entity-id.jsonc @@ -0,0 +1,14 @@ +// Filter by full EntityId input. Verifies EntityId struct decoding +// (web_id, entity_uuid, draft_id) through the parameter binding path. +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + ["==", + "vertex.metadata.record_id.entity_id", + ["input", "alice_id", "::graph::types::knowledge::entity::EntityId"] + ] + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-by-entity-id.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-by-entity-id.stdout new file mode 100644 index 00000000000..72222e8bcdc --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-by-entity-id.stdout @@ -0,0 +1,40 @@ +[ + { + "metadata": { + "record_id": { + "entity_id": { + "draft_id": null, + "entity_uuid": "", + "web_id": "" + } + } + } + } +] +--- +query executed: body 3, block bb0 +row received +filter started: body 2 +island entered: body 2, island 0, target interpreter +filter accepted: body 2 +row accepted +row received +filter started: body 2 +island entered: body 2, island 0, target interpreter +filter rejected: body 2 +row rejected +row received +filter started: body 2 +island entered: body 2, island 0, target interpreter +filter rejected: body 2 +row rejected +row received +filter started: body 2 +island entered: body 2, island 0, target interpreter +filter rejected: body 2 +row rejected +row received +filter started: body 2 +island entered: body 2, island 0, target interpreter +filter rejected: body 2 +row rejected diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-by-uuid.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-by-uuid.jsonc new file mode 100644 index 00000000000..bdaf20bfb3c --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-by-uuid.jsonc @@ -0,0 +1,13 @@ +// Filter entities by entity_uuid matching Alice. +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + ["==", + "vertex.metadata.record_id.entity_id.entity_uuid", + ["input", "alice_uuid", "::graph::types::knowledge::entity::EntityUuid"] + ] + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-by-uuid.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-by-uuid.stdout new file mode 100644 index 00000000000..14e3bc7b908 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-by-uuid.stdout @@ -0,0 +1,11 @@ +[ + {} +] +--- +query executed: body 3, block bb0 +row received +filter started: body 2 +island entered: body 2, island 0, target postgres +continuation implicit true: body 2 +filter accepted: body 2 +row accepted diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-diamond-cfg.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-diamond-cfg.jsonc new file mode 100644 index 00000000000..1b160c23cd9 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-diamond-cfg.jsonc @@ -0,0 +1,26 @@ +// Diamond CFG in filter: discriminant depends on the vertex. +// If entity_uuid matches Alice, compare against Alice's full EntityId. +// Otherwise compare entity_uuid against Bob's EntityUuid. +// Expected result: Alice (first arm) and Bob (second arm). +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + ["if", + ["==", + "vertex.metadata.record_id.entity_id.entity_uuid", + ["input", "alice_uuid", "::graph::types::knowledge::entity::EntityUuid"] + ], + ["==", + "vertex.metadata.record_id.entity_id", + ["input", "alice_id", "::graph::types::knowledge::entity::EntityId"] + ], + ["==", + "vertex.metadata.record_id.entity_id.entity_uuid", + ["input", "bob_uuid", "::graph::types::knowledge::entity::EntityUuid"] + ] + ] + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-diamond-cfg.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-diamond-cfg.stdout new file mode 100644 index 00000000000..41b0688b5c7 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-diamond-cfg.stdout @@ -0,0 +1,39 @@ +[ + { + "metadata": { + "record_id": { + "entity_id": { + "draft_id": null, + "entity_uuid": "", + "web_id": "" + } + } + } + }, + { + "metadata": { + "record_id": { + "entity_id": { + "draft_id": null, + "entity_uuid": "", + "web_id": "" + } + } + } + } +] +--- +query executed: body 3, block bb0 +row received +filter started: body 2 +island entered: body 2, island 0, target postgres +continuation flushed: body 2, island 0 +island entered: body 2, island 1, target interpreter +filter accepted: body 2 +row accepted +row received +filter started: body 2 +island entered: body 2, island 0, target postgres +continuation implicit true: body 2 +filter accepted: body 2 +row accepted diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-false.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-false.jsonc new file mode 100644 index 00000000000..b50a8342226 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-false.jsonc @@ -0,0 +1,10 @@ +// Filter that rejects all entities. Result should be an empty list. +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + {"#literal": false} + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-false.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-false.stdout new file mode 100644 index 00000000000..aac4e2f4a36 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-false.stdout @@ -0,0 +1,28 @@ +[] +--- +query executed: body 2, block bb0 +row received +filter started: body 1 +island entered: body 1, island 0, target interpreter +filter rejected: body 1 +row rejected +row received +filter started: body 1 +island entered: body 1, island 0, target interpreter +filter rejected: body 1 +row rejected +row received +filter started: body 1 +island entered: body 1, island 0, target interpreter +filter rejected: body 1 +row rejected +row received +filter started: body 1 +island entered: body 1, island 0, target interpreter +filter rejected: body 1 +row rejected +row received +filter started: body 1 +island entered: body 1, island 0, target interpreter +filter rejected: body 1 +row rejected diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-not-equal.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-not-equal.jsonc new file mode 100644 index 00000000000..7a82823fc31 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-not-equal.jsonc @@ -0,0 +1,13 @@ +// Filter entities where entity_uuid != Alice. Should exclude Alice. +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + ["!=", + "vertex.metadata.record_id.entity_id.entity_uuid", + ["input", "alice_uuid", "::graph::types::knowledge::entity::EntityUuid"] + ] + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-not-equal.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-not-equal.stdout new file mode 100644 index 00000000000..61037802f8d --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-not-equal.stdout @@ -0,0 +1,32 @@ +[ + {}, + {}, + {}, + {} +] +--- +query executed: body 3, block bb0 +row received +filter started: body 2 +island entered: body 2, island 0, target postgres +continuation implicit true: body 2 +filter accepted: body 2 +row accepted +row received +filter started: body 2 +island entered: body 2, island 0, target postgres +continuation implicit true: body 2 +filter accepted: body 2 +row accepted +row received +filter started: body 2 +island entered: body 2, island 0, target postgres +continuation implicit true: body 2 +filter accepted: body 2 +row accepted +row received +filter started: body 2 +island entered: body 2, island 0, target postgres +continuation implicit true: body 2 +filter accepted: body 2 +row accepted diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-sequential.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-sequential.jsonc new file mode 100644 index 00000000000..1a74826fde5 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-sequential.jsonc @@ -0,0 +1,40 @@ +// Two sequential filters on the same graph read. +// First filter: exclude non-person entities (org, draft, link) by +// requiring entity_uuid != org_uuid AND entity_uuid != draft_alice_uuid +// AND entity_uuid != friend_link_uuid. Keeps Alice and Bob. +// Second filter: keep only Alice (entity_uuid == alice_uuid). +// Net result: only Alice survives. +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + ["if", + ["!=", + "vertex.metadata.record_id.entity_id.entity_uuid", + ["input", "org_uuid", "::graph::types::knowledge::entity::EntityUuid"] + ], + ["if", + ["!=", + "vertex.metadata.record_id.entity_id.entity_uuid", + ["input", "draft_alice_uuid", "::graph::types::knowledge::entity::EntityUuid"] + ], + ["!=", + "vertex.metadata.record_id.entity_id.entity_uuid", + ["input", "friend_link_uuid", "::graph::types::knowledge::entity::EntityUuid"] + ], + { "#literal": false } + ], + { "#literal": false } + ] + ] + ], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + ["==", + "vertex.metadata.record_id.entity_id.entity_uuid", + ["input", "alice_uuid", "::graph::types::knowledge::entity::EntityUuid"] + ] + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-sequential.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-sequential.stdout new file mode 100644 index 00000000000..00e00935892 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/filter-sequential.stdout @@ -0,0 +1,15 @@ +[ + {} +] +--- +query executed: body 5, block bb0 +row received +filter started: body 3 +island entered: body 3, island 0, target postgres +continuation implicit true: body 3 +filter accepted: body 3 +filter started: body 4 +island entered: body 4, island 0, target postgres +continuation implicit true: body 4 +filter accepted: body 4 +row accepted diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/has-link-data.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/has-link-data.jsonc new file mode 100644 index 00000000000..c1ba55cc743 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/has-link-data.jsonc @@ -0,0 +1,12 @@ +// Filter entities where link_data is present (link entities only). +// Verifies PartialLinkData hydration from real LEFT JOIN columns: +// entity IDs, confidence, provenance. +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + ["!=", "vertex.link_data", ["None"]] + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/has-link-data.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/has-link-data.stdout new file mode 100644 index 00000000000..f4a041e9b37 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/has-link-data.stdout @@ -0,0 +1,95 @@ +[ + { + "link_data": { + "left_entity_confidence": 0.9, + "left_entity_id": { + "draft_id": null, + "entity_uuid": "", + "web_id": "" + }, + "left_entity_provenance": {}, + "right_entity_confidence": 0.8, + "right_entity_id": { + "draft_id": null, + "entity_uuid": "", + "web_id": "" + }, + "right_entity_provenance": {} + }, + "metadata": { + "archived": false, + "confidence": null, + "entity_type_ids": [ + { + "base_url": "https://blockprotocol.org/@alice/types/entity-type/friend-of/", + "version": "1" + } + ], + "property_metadata": { + "value": {} + }, + "provenance": { + "edition": { + "actorType": "user", + "createdById": "", + "origin": { + "type": "api" + } + }, + "inferred": { + "createdAtDecisionTime": "", + "createdAtTransactionTime": "", + "createdById": "", + "firstNonDraftCreatedAtDecisionTime": "", + "firstNonDraftCreatedAtTransactionTime": "" + } + }, + "record_id": { + "edition_id": "", + "entity_id": { + "draft_id": null, + "entity_uuid": "", + "web_id": "" + } + }, + "temporal_versioning": { + "decision_time": { + "end": null, + "start": + }, + "transaction_time": { + "end": null, + "start": + } + } + }, + "properties": {} + } +] +--- +query executed: body 5, block bb0 +row received +filter started: body 4 +island entered: body 4, island 0, target interpreter +filter rejected: body 4 +row rejected +row received +filter started: body 4 +island entered: body 4, island 0, target interpreter +filter rejected: body 4 +row rejected +row received +filter started: body 4 +island entered: body 4, island 0, target interpreter +filter rejected: body 4 +row rejected +row received +filter started: body 4 +island entered: body 4, island 0, target interpreter +filter rejected: body 4 +row rejected +row received +filter started: body 4 +island entered: body 4, island 0, target interpreter +filter accepted: body 4 +row accepted diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/let-binding.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/let-binding.jsonc new file mode 100644 index 00000000000..f831079d4a3 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/let-binding.jsonc @@ -0,0 +1,16 @@ +// Let binding propagation into filter body. +// biome-ignore format: readability +["let", "target", + ["input", "alice_uuid", "::graph::types::knowledge::entity::EntityUuid"], + ["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + ["==", + "vertex.metadata.record_id.entity_id.entity_uuid", + "target" + ] + ] + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/let-binding.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/let-binding.stdout new file mode 100644 index 00000000000..14e3bc7b908 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/let-binding.stdout @@ -0,0 +1,11 @@ +[ + {} +] +--- +query executed: body 3, block bb0 +row received +filter started: body 2 +island entered: body 2, island 0, target postgres +continuation implicit true: body 2 +filter accepted: body 2 +row accepted diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/metadata-leaf-fields.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/metadata-leaf-fields.jsonc new file mode 100644 index 00000000000..135b899d7d8 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/metadata-leaf-fields.jsonc @@ -0,0 +1,25 @@ +// Filter using three different metadata leaf column types: +// entity_uuid (UUID/TEXT), web_id (UUID/TEXT), archived (BOOL). +// All seeded entities share the same web_id. Combining all three +// conditions selects only non-archived Alice. +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + ["&&", + ["&&", + ["==", + "vertex.metadata.record_id.entity_id.entity_uuid", + ["input", "alice_uuid", "::graph::types::knowledge::entity::EntityUuid"] + ], + ["==", + "vertex.metadata.record_id.entity_id.web_id", + ["input", "web_id", "::graph::types::principal::actor_group::web::WebId"] + ] + ], + ["==", "vertex.metadata.archived", { "#literal": false }] + ] + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/metadata-leaf-fields.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/metadata-leaf-fields.stdout new file mode 100644 index 00000000000..14e3bc7b908 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/metadata-leaf-fields.stdout @@ -0,0 +1,11 @@ +[ + {} +] +--- +query executed: body 3, block bb0 +row received +filter started: body 2 +island entered: body 2, island 0, target postgres +continuation implicit true: body 2 +filter accepted: body 2 +row accepted diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/null-link-data.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/null-link-data.jsonc new file mode 100644 index 00000000000..2c5a51053a5 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/null-link-data.jsonc @@ -0,0 +1,11 @@ +// Filter entities where link_data is None (non-link entities). +// Verifies Optional::Null comparison against real NULL LEFT JOIN columns. +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + ["==", "vertex.link_data", ["None"]] + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/null-link-data.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/null-link-data.stdout new file mode 100644 index 00000000000..9297ac31ae9 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/null-link-data.stdout @@ -0,0 +1,289 @@ +[ + { + "link_data": null, + "metadata": { + "archived": false, + "confidence": null, + "entity_type_ids": [ + { + "base_url": "https://blockprotocol.org/@alice/types/entity-type/person/", + "version": "1" + } + ], + "property_metadata": { + "value": { + "https://blockprotocol.org/@alice/types/property-type/name/": { + "metadata": { + "canonical": { + "https://blockprotocol.org/@blockprotocol/types/data-type/text/": "Alice" + }, + "dataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1", + "originalDataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1" + } + } + } + }, + "provenance": { + "edition": { + "actorType": "user", + "createdById": "", + "origin": { + "type": "api" + } + }, + "inferred": { + "createdAtDecisionTime": "", + "createdAtTransactionTime": "", + "createdById": "", + "firstNonDraftCreatedAtDecisionTime": "", + "firstNonDraftCreatedAtTransactionTime": "" + } + }, + "record_id": { + "edition_id": "", + "entity_id": { + "draft_id": null, + "entity_uuid": "", + "web_id": "" + } + }, + "temporal_versioning": { + "decision_time": { + "end": null, + "start": + }, + "transaction_time": { + "end": null, + "start": + } + } + }, + "properties": { + "https://blockprotocol.org/@alice/types/property-type/name/": "Alice" + } + }, + { + "link_data": null, + "metadata": { + "archived": false, + "confidence": null, + "entity_type_ids": [ + { + "base_url": "https://blockprotocol.org/@alice/types/entity-type/person/", + "version": "1" + } + ], + "property_metadata": { + "value": { + "https://blockprotocol.org/@alice/types/property-type/age/": { + "metadata": { + "canonical": { + "https://blockprotocol.org/@blockprotocol/types/data-type/number/": 42.0 + }, + "dataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/number/v/1", + "originalDataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/number/v/1" + } + }, + "https://blockprotocol.org/@alice/types/property-type/name/": { + "metadata": { + "canonical": { + "https://blockprotocol.org/@blockprotocol/types/data-type/text/": "Bob" + }, + "dataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1", + "originalDataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1" + } + } + } + }, + "provenance": { + "edition": { + "actorType": "user", + "createdById": "", + "origin": { + "type": "api" + } + }, + "inferred": { + "createdAtDecisionTime": "", + "createdAtTransactionTime": "", + "createdById": "", + "firstNonDraftCreatedAtDecisionTime": "", + "firstNonDraftCreatedAtTransactionTime": "" + } + }, + "record_id": { + "edition_id": "", + "entity_id": { + "draft_id": null, + "entity_uuid": "", + "web_id": "" + } + }, + "temporal_versioning": { + "decision_time": { + "end": null, + "start": + }, + "transaction_time": { + "end": null, + "start": + } + } + }, + "properties": { + "https://blockprotocol.org/@alice/types/property-type/age/": 42.0, + "https://blockprotocol.org/@alice/types/property-type/name/": "Bob" + } + }, + { + "link_data": null, + "metadata": { + "archived": false, + "confidence": null, + "entity_type_ids": [ + { + "base_url": "https://blockprotocol.org/@alice/types/entity-type/organization/", + "version": "1" + } + ], + "property_metadata": { + "value": { + "https://blockprotocol.org/@alice/types/property-type/name/": { + "metadata": { + "canonical": { + "https://blockprotocol.org/@blockprotocol/types/data-type/text/": "HASH, Ltd" + }, + "dataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1", + "originalDataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1" + } + } + } + }, + "provenance": { + "edition": { + "actorType": "user", + "createdById": "", + "origin": { + "type": "api" + } + }, + "inferred": { + "createdAtDecisionTime": "", + "createdAtTransactionTime": "", + "createdById": "", + "firstNonDraftCreatedAtDecisionTime": "", + "firstNonDraftCreatedAtTransactionTime": "" + } + }, + "record_id": { + "edition_id": "", + "entity_id": { + "draft_id": null, + "entity_uuid": "", + "web_id": "" + } + }, + "temporal_versioning": { + "decision_time": { + "end": null, + "start": + }, + "transaction_time": { + "end": null, + "start": + } + } + }, + "properties": { + "https://blockprotocol.org/@alice/types/property-type/name/": "HASH, Ltd" + } + }, + { + "link_data": null, + "metadata": { + "archived": false, + "confidence": null, + "entity_type_ids": [ + { + "base_url": "https://blockprotocol.org/@alice/types/entity-type/person/", + "version": "1" + } + ], + "property_metadata": { + "value": { + "https://blockprotocol.org/@alice/types/property-type/name/": { + "metadata": { + "canonical": { + "https://blockprotocol.org/@blockprotocol/types/data-type/text/": "Alice" + }, + "dataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1", + "originalDataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1" + } + } + } + }, + "provenance": { + "edition": { + "actorType": "user", + "createdById": "", + "origin": { + "type": "api" + } + }, + "inferred": { + "createdAtDecisionTime": "", + "createdAtTransactionTime": "", + "createdById": "" + } + }, + "record_id": { + "edition_id": "", + "entity_id": { + "draft_id": "", + "entity_uuid": "", + "web_id": "" + } + }, + "temporal_versioning": { + "decision_time": { + "end": null, + "start": + }, + "transaction_time": { + "end": null, + "start": + } + } + }, + "properties": { + "https://blockprotocol.org/@alice/types/property-type/name/": "Alice" + } + } +] +--- +query executed: body 5, block bb0 +row received +filter started: body 4 +island entered: body 4, island 0, target interpreter +filter accepted: body 4 +row accepted +row received +filter started: body 4 +island entered: body 4, island 0, target interpreter +filter accepted: body 4 +row accepted +row received +filter started: body 4 +island entered: body 4, island 0, target interpreter +filter accepted: body 4 +row accepted +row received +filter started: body 4 +island entered: body 4, island 0, target interpreter +filter accepted: body 4 +row accepted +row received +filter started: body 4 +island entered: body 4, island 0, target interpreter +filter rejected: body 4 +row rejected diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/organization-type.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/organization-type.jsonc new file mode 100644 index 00000000000..4bf593907ed --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/organization-type.jsonc @@ -0,0 +1,25 @@ +// Filter entities by entity_type_ids matching the Organization type. +// Only the organization entity should survive. +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + ["==", + "vertex.metadata.entity_type_ids", + { "#list": [ + ["::graph::types::ontology::VersionedUrl", { "#struct": { + "base_url": ["::graph::types::ontology::BaseUrl", + ["::core::url::Url", + { "#literal": "https://blockprotocol.org/@alice/types/entity-type/organization/" } + ] + ], + "version": ["::graph::types::ontology::OntologyTypeVersion", + { "#literal": "1" } + ] + }}] + ]} + ] + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/organization-type.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/organization-type.stdout new file mode 100644 index 00000000000..2e88f4d97e4 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/organization-type.stdout @@ -0,0 +1,11 @@ +[ + {} +] +--- +query executed: body 16, block bb0 +row received +filter started: body 15 +island entered: body 15, island 0, target postgres +continuation implicit true: body 15 +filter accepted: body 15 +row accepted diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/pinned-decision-time.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/pinned-decision-time.jsonc new file mode 100644 index 00000000000..01d2b3290d8 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/pinned-decision-time.jsonc @@ -0,0 +1,12 @@ +//@ axis[decision] = (946684800000) +// Pin decision time to 2000-01-01T00:00:00Z. +// Verifies TemporalInterval wire encoding is accepted by PostgreSQL. +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + {"#literal": true} + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/pinned-decision-time.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/pinned-decision-time.stdout new file mode 100644 index 00000000000..0a280baf2e7 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/pinned-decision-time.stdout @@ -0,0 +1,3 @@ +[] +--- +query executed: body 2, block bb0 diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/simple-read.jsonc b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/simple-read.jsonc new file mode 100644 index 00000000000..57692475193 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/simple-read.jsonc @@ -0,0 +1,10 @@ +// All entities, trivial filter. Baseline for the full pipeline. +// biome-ignore format: readability +["::graph::tail::collect", + ["::graph::body::filter", + ["::graph::head::entities", ["input", "temporal_axes", "_"]], + ["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_", + {"#literal": true} + ] + ] +] diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/simple-read.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/simple-read.stdout new file mode 100644 index 00000000000..de0a09441b1 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/jsonc/simple-read.stdout @@ -0,0 +1,34 @@ +[ + {}, + {}, + {}, + {}, + {} +] +--- +query executed: body 2, block bb0 +row received +filter started: body 1 +island entered: body 1, island 0, target interpreter +filter accepted: body 1 +row accepted +row received +filter started: body 1 +island entered: body 1, island 0, target interpreter +filter accepted: body 1 +row accepted +row received +filter started: body 1 +island entered: body 1, island 0, target interpreter +filter accepted: body 1 +row accepted +row received +filter started: body 1 +island entered: body 1, island 0, target interpreter +filter accepted: body 1 +row accepted +row received +filter started: body 1 +island entered: body 1, island 0, target interpreter +filter accepted: body 1 +row accepted diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/programmatic/property-access.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/programmatic/property-access.stdout new file mode 100644 index 00000000000..30727c313fa --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/programmatic/property-access.stdout @@ -0,0 +1,18 @@ +[ + {}, + {} +] +--- +query executed: body 0, block bb0 +row received +filter started: body 1 +island entered: body 1, island 0, target postgres +continuation implicit true: body 1 +filter accepted: body 1 +row accepted +row received +filter started: body 1 +island entered: body 1, island 0, target postgres +continuation implicit true: body 1 +filter accepted: body 1 +row accepted diff --git a/libs/@local/hashql/eval/tests/ui/orchestrator/programmatic/property-arithmetic.stdout b/libs/@local/hashql/eval/tests/ui/orchestrator/programmatic/property-arithmetic.stdout new file mode 100644 index 00000000000..98a4a63bdf5 --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/orchestrator/programmatic/property-arithmetic.stdout @@ -0,0 +1,11 @@ +[ + {} +] +--- +query executed: body 0, block bb0 +row received +filter started: body 1 +island entered: body 1, island 0, target postgres +continuation implicit true: body 1 +filter accepted: body 1 +row accepted diff --git a/libs/@local/hashql/eval/tests/ui/postgres/comparison-no-cast.stdout b/libs/@local/hashql/eval/tests/ui/postgres/comparison-no-cast.stdout index 4670536a811..ff997416812 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/comparison-no-cast.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/comparison-no-cast.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_4_0"."row")."block" AS "continuation_4_0_block", ("continuation_4_0"."row")."locals" AS "continuation_4_0_locals", ("continuation_4_0"."row")."values" AS "continuation_4_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW((($3 > $4)::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((($3::jsonb) > ($4::jsonb))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_4_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_4_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_4_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/constant-true-filter.stdout b/libs/@local/hashql/eval/tests/ui/postgres/constant-true-filter.stdout index 5f9c348d0e4..841d0a8e621 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/constant-true-filter.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/constant-true-filter.stdout @@ -2,7 +2,7 @@ SELECT 1 AS "placeholder" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/dict-construction.stdout b/libs/@local/hashql/eval/tests/ui/postgres/dict-construction.stdout index 7a347f26c72..f649dadb5f0 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/dict-construction.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/dict-construction.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_4_0"."row")."block" AS "continuation_4_0_block", ("continuation_4_0"."row")."locals" AS "continuation_4_0_locals", ("continuation_4_0"."row")."values" AS "continuation_4_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW(((jsonb_build_object("entity_temporal_metadata_0_0_0"."entity_uuid", "entity_temporal_metadata_0_0_0"."web_id") = jsonb_build_object($3, $4))::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb(jsonb_build_object("entity_temporal_metadata_0_0_0"."entity_uuid", "entity_temporal_metadata_0_0_0"."web_id")) = to_jsonb(jsonb_build_object(($3::jsonb), ($4::jsonb))))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_4_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_4_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_4_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/entity-archived-check.stdout b/libs/@local/hashql/eval/tests/ui/postgres/entity-archived-check.stdout index 355aa0016a8..3ca5b2553d6 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/entity-archived-check.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/entity-archived-check.stdout @@ -4,9 +4,9 @@ SELECT ("continuation_1_0"."row")."block" AS "continuation_1_0_block", ("continu FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" INNER JOIN "entity_editions" AS "entity_editions_0_0_1" ON "entity_editions_0_0_1"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" -CROSS JOIN LATERAL (SELECT (ROW(((NOT("entity_editions_0_0_1"."archived"))::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((NOT("entity_editions_0_0_1"."archived"))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_1_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_1_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_1_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/entity-draft-id-equality.stdout b/libs/@local/hashql/eval/tests/ui/postgres/entity-draft-id-equality.stdout index 0632230eca6..8fcadb313b4 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/entity-draft-id-equality.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/entity-draft-id-equality.stdout @@ -2,7 +2,7 @@ SELECT "entity_temporal_metadata_0_0_0"."draft_id" AS "draft_id" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/entity-type-ids-lateral.stdout b/libs/@local/hashql/eval/tests/ui/postgres/entity-type-ids-lateral.stdout index 5d666552cf4..8e2ab88b4d8 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/entity-type-ids-lateral.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/entity-type-ids-lateral.stdout @@ -2,14 +2,14 @@ SELECT ("continuation_2_0"."row")."block" AS "continuation_2_0_block", ("continuation_2_0"."row")."locals" AS "continuation_2_0_locals", ("continuation_2_0"."row")."values" AS "continuation_2_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -LEFT OUTER JOIN LATERAL (SELECT jsonb_agg(jsonb_build_object($4, "b", $5, "v")) AS "entity_type_ids" +LEFT OUTER JOIN LATERAL (SELECT jsonb_agg(jsonb_build_object(($4::text), "b", ($5::text), "v")) AS "entity_type_ids" FROM "entity_is_of_type_ids" AS "eit" -CROSS JOIN UNNEST("eit"."base_urls", "eit"."versions") AS "u"("b", "v") +CROSS JOIN UNNEST("eit"."base_urls", ("eit"."versions"::text[])) AS "u"("b", "v") WHERE "eit"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id") AS "entity_is_of_type_ids_0_0_1" ON TRUE -CROSS JOIN LATERAL (SELECT (ROW((("entity_is_of_type_ids_0_0_1"."entity_type_ids" = $3)::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_is_of_type_ids_0_0_1"."entity_type_ids") = to_jsonb(($3::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_2_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_2_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_2_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/entity-uuid-equality.stdout b/libs/@local/hashql/eval/tests/ui/postgres/entity-uuid-equality.stdout index 751694e918e..30bad1b3f91 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/entity-uuid-equality.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/entity-uuid-equality.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_7_0"."row")."block" AS "continuation_7_0_block", ("continuation_7_0"."row")."locals" AS "continuation_7_0_locals", ("continuation_7_0"."row")."values" AS "continuation_7_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW((("entity_temporal_metadata_0_0_0"."entity_uuid" = $3)::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."entity_uuid") = to_jsonb(($3::text)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_7_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_7_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_7_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/entity-web-id-equality.stdout b/libs/@local/hashql/eval/tests/ui/postgres/entity-web-id-equality.stdout index 9fca9c14ac6..fe5d03119a3 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/entity-web-id-equality.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/entity-web-id-equality.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_2_0"."row")."block" AS "continuation_2_0_block", ("continuation_2_0"."row")."locals" AS "continuation_2_0_locals", ("continuation_2_0"."row")."values" AS "continuation_2_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW((("entity_temporal_metadata_0_0_0"."web_id" = $3)::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."web_id") = to_jsonb(($3::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_2_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_2_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_2_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/env-captured-variable.stdout b/libs/@local/hashql/eval/tests/ui/postgres/env-captured-variable.stdout index bb92d00015d..aced634948f 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/env-captured-variable.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/env-captured-variable.stdout @@ -2,12 +2,12 @@ SELECT ("continuation_0_0"."row")."block" AS "continuation_0_0_block", ("continuation_0_0"."row")."locals" AS "continuation_0_0_locals", ("continuation_0_0"."row")."values" AS "continuation_0_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW((("entity_temporal_metadata_0_0_0"."entity_uuid" = $3)::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."entity_uuid") = to_jsonb(($3::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_0_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_0_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_0_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ $1: TemporalAxis(Transaction) $2: TemporalAxis(Decision) -$3: Env(0, #0) +$3: Env(%3, #0) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/binary_bitand_bigint_cast.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/binary_bitand_bigint_cast.snap index 85678110144..e027f5f2c49 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/binary_bitand_bigint_cast.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/binary_bitand_bigint_cast.snap @@ -19,4 +19,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Integer { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW((((($1)::bigint) & (($2)::bigint))::boolean), NULL, NULL, NULL)::continuation) +(ROW(COALESCE(((((($1::jsonb))::bigint) & ((($2::jsonb))::bigint))::boolean), FALSE), NULL, NULL, NULL)::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/binary_sub_numeric_cast.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/binary_sub_numeric_cast.snap index 953457d9dc9..3721c69cff6 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/binary_sub_numeric_cast.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/binary_sub_numeric_cast.snap @@ -19,4 +19,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Integer { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW((((($1)::numeric) - (($2)::numeric))::boolean), NULL, NULL, NULL)::continuation) +(ROW(COALESCE(((((($1::jsonb))::numeric) - ((($2::jsonb))::numeric))::boolean), FALSE), NULL, NULL, NULL)::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/data_island_provides_without_lateral.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/data_island_provides_without_lateral.snap index 0ebf5134aa9..f2b4f8fe913 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/data_island_provides_without_lateral.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/data_island_provides_without_lateral.snap @@ -19,16 +19,16 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> ? { } ===================================== SQL ====================================== -SELECT "entity_editions_0_0_1"."properties" AS "properties", jsonb_build_object($3, jsonb_build_object($4, "entity_temporal_metadata_0_0_0"."web_id", $5, "entity_temporal_metadata_0_0_0"."entity_uuid", $6, "entity_temporal_metadata_0_0_0"."draft_id"), $6, "entity_temporal_metadata_0_0_0"."draft_id") AS "record_id", jsonb_build_object($7, "entity_temporal_metadata_0_0_0"."decision_time", $8, "entity_temporal_metadata_0_0_0"."transaction_time") AS "temporal_versioning", "entity_is_of_type_ids_0_0_2"."entity_type_ids" AS "entity_type_ids", "entity_editions_0_0_1"."archived" AS "archived", "entity_editions_0_0_1"."confidence" AS "confidence", "entity_ids_0_0_3"."provenance" AS "provenance_inferred", "entity_editions_0_0_1"."provenance" AS "provenance_edition", "entity_editions_0_0_1"."property_metadata" AS "property_metadata", "entity_has_left_entity_0_0_4"."left_web_id" AS "left_entity_web_id", "entity_has_left_entity_0_0_4"."left_entity_uuid" AS "left_entity_uuid", "entity_has_right_entity_0_0_5"."right_web_id" AS "right_entity_web_id", "entity_has_right_entity_0_0_5"."right_entity_uuid" AS "right_entity_uuid", "entity_has_left_entity_0_0_4"."confidence" AS "left_entity_confidence", "entity_has_right_entity_0_0_5"."confidence" AS "right_entity_confidence", "entity_has_left_entity_0_0_4"."provenance" AS "left_entity_provenance", "entity_has_right_entity_0_0_5"."provenance" AS "right_entity_provenance" +SELECT "entity_editions_0_0_1"."properties" AS "properties", jsonb_build_object(($3::text), jsonb_build_object(($4::text), "entity_temporal_metadata_0_0_0"."web_id", ($5::text), "entity_temporal_metadata_0_0_0"."entity_uuid", ($6::text), "entity_temporal_metadata_0_0_0"."draft_id"), ($7::text), "entity_temporal_metadata_0_0_0"."entity_edition_id") AS "record_id", jsonb_build_object(($8::text), jsonb_build_object(($9::text), (extract(epoch from lower("entity_temporal_metadata_0_0_0"."decision_time")) * 1000)::int8, ($10::text), CASE WHEN upper_inf("entity_temporal_metadata_0_0_0"."decision_time") THEN NULL ELSE (extract(epoch from upper("entity_temporal_metadata_0_0_0"."decision_time")) * 1000)::int8 END), ($11::text), jsonb_build_object(($9::text), (extract(epoch from lower("entity_temporal_metadata_0_0_0"."transaction_time")) * 1000)::int8, ($10::text), CASE WHEN upper_inf("entity_temporal_metadata_0_0_0"."transaction_time") THEN NULL ELSE (extract(epoch from upper("entity_temporal_metadata_0_0_0"."transaction_time")) * 1000)::int8 END)) AS "temporal_versioning", "entity_is_of_type_ids_0_0_2"."entity_type_ids" AS "entity_type_ids", "entity_editions_0_0_1"."archived" AS "archived", "entity_editions_0_0_1"."confidence" AS "confidence", "entity_ids_0_0_3"."provenance" AS "provenance_inferred", "entity_editions_0_0_1"."provenance" AS "provenance_edition", "entity_editions_0_0_1"."property_metadata" AS "property_metadata", "entity_has_left_entity_0_0_4"."left_web_id" AS "left_entity_web_id", "entity_has_left_entity_0_0_4"."left_entity_uuid" AS "left_entity_uuid", "entity_has_right_entity_0_0_5"."right_web_id" AS "right_entity_web_id", "entity_has_right_entity_0_0_5"."right_entity_uuid" AS "right_entity_uuid", "entity_has_left_entity_0_0_4"."confidence" AS "left_entity_confidence", "entity_has_right_entity_0_0_5"."confidence" AS "right_entity_confidence", "entity_has_left_entity_0_0_4"."provenance" AS "left_entity_provenance", "entity_has_right_entity_0_0_5"."provenance" AS "right_entity_provenance" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" INNER JOIN "entity_editions" AS "entity_editions_0_0_1" ON "entity_editions_0_0_1"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" INNER JOIN "entity_ids" AS "entity_ids_0_0_3" ON "entity_ids_0_0_3"."web_id" = "entity_temporal_metadata_0_0_0"."web_id" AND "entity_ids_0_0_3"."entity_uuid" = "entity_temporal_metadata_0_0_0"."entity_uuid" -LEFT OUTER JOIN LATERAL (SELECT jsonb_agg(jsonb_build_object($9, "b", $10, "v")) AS "entity_type_ids" +LEFT OUTER JOIN LATERAL (SELECT jsonb_agg(jsonb_build_object(($12::text), "b", ($13::text), "v")) AS "entity_type_ids" FROM "entity_is_of_type_ids" AS "eit" -CROSS JOIN UNNEST("eit"."base_urls", "eit"."versions") AS "u"("b", "v") +CROSS JOIN UNNEST("eit"."base_urls", ("eit"."versions"::text[])) AS "u"("b", "v") WHERE "eit"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id") AS "entity_is_of_type_ids_0_0_2" ON TRUE LEFT OUTER JOIN "entity_has_left_entity" AS "entity_has_left_entity_0_0_4" @@ -37,7 +37,7 @@ LEFT OUTER JOIN "entity_has_left_entity" AS "entity_has_left_entity_0_0_4" LEFT OUTER JOIN "entity_has_right_entity" AS "entity_has_right_entity_0_0_5" ON "entity_has_right_entity_0_0_5"."web_id" = "entity_temporal_metadata_0_0_0"."web_id" AND "entity_has_right_entity_0_0_5"."entity_uuid" = "entity_temporal_metadata_0_0_0"."entity_uuid" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) ================================== Parameters ================================== $1: TemporalAxis(Transaction) @@ -46,7 +46,10 @@ $3: Symbol(entity_id) $4: Symbol(web_id) $5: Symbol(entity_uuid) $6: Symbol(draft_id) -$7: Symbol(decision_time) -$8: Symbol(transaction_time) -$9: Symbol(base_url) -$10: Symbol(version) +$7: Symbol(edition_id) +$8: Symbol(decision_time) +$9: Symbol(start) +$10: Symbol(end) +$11: Symbol(transaction_time) +$12: Symbol(base_url) +$13: Symbol(version) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/diamond_cfg_merge.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/diamond_cfg_merge.snap index d0f27457b6e..3942a92f2f1 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/diamond_cfg_merge.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/diamond_cfg_merge.snap @@ -28,4 +28,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Boolean { } ==================== Island (entry: bb0, target: postgres) ===================== -CASE WHEN (($1)::int) = 0 THEN (ROW(((0)::boolean), NULL, NULL, NULL)::continuation) WHEN (($1)::int) = 1 THEN (ROW(((1)::boolean), NULL, NULL, NULL)::continuation) END +CASE WHEN ((($1::jsonb))::int) IS NULL THEN (ROW(COALESCE(((FALSE)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($1::jsonb))::int) = 0 THEN (ROW(COALESCE(((0)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($1::jsonb))::int) = 1 THEN (ROW(COALESCE(((1)::boolean), FALSE), NULL, NULL, NULL)::continuation) END diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/dynamic_index_projection.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/dynamic_index_projection.snap index 811e9a41782..645e5c6470d 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/dynamic_index_projection.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/dynamic_index_projection.snap @@ -19,4 +19,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Integer { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW(((jsonb_extract_path(jsonb_build_array(10, 20, 30), (($1)::text)))::boolean), NULL, NULL, NULL)::continuation) +(ROW(COALESCE(((jsonb_extract_path(jsonb_build_array(10, 20, 30), ((($1::jsonb))::text)))::boolean), FALSE), NULL, NULL, NULL)::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/field_by_name_projection.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/field_by_name_projection.snap index a1a8be44074..0ad533269c0 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/field_by_name_projection.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/field_by_name_projection.snap @@ -17,4 +17,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Integer { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW(((jsonb_extract_path(jsonb_build_object($1, 10, $2, 20), (($1)::text)))::boolean), NULL, NULL, NULL)::continuation) +(ROW(COALESCE(((jsonb_extract_path(jsonb_build_object(($1::text), 10, ($2::text), 20), ((($1::text))::text)))::boolean), FALSE), NULL, NULL, NULL)::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/field_index_projection.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/field_index_projection.snap index f31c12cb2f1..12831fd3d9c 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/field_index_projection.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/field_index_projection.snap @@ -17,4 +17,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Integer { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW(((jsonb_extract_path(jsonb_build_array(10, 20), ((0)::text)))::boolean), NULL, NULL, NULL)::continuation) +(ROW(COALESCE(((jsonb_extract_path(jsonb_build_array(10, 20), ((0)::text)))::boolean), FALSE), NULL, NULL, NULL)::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/island_exit_goto.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/island_exit_goto.snap index 8bf1ba2944d..b7cbe6f2871 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/island_exit_goto.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/island_exit_goto.snap @@ -32,4 +32,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> ? { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW(NULL, 1, ARRAY[8]::int[], ARRAY[jsonb_build_object($1, jsonb_build_object($2, "entity_temporal_metadata_0_0_0"."web_id", $3, "entity_temporal_metadata_0_0_0"."entity_uuid", $4, "entity_temporal_metadata_0_0_0"."draft_id"), $4, "entity_temporal_metadata_0_0_0"."draft_id")]::jsonb[])::continuation) +(ROW(NULL, 1, ARRAY[8]::int[], ARRAY[jsonb_build_object(($1::text), jsonb_build_object(($2::text), "entity_temporal_metadata_0_0_0"."web_id", ($3::text), "entity_temporal_metadata_0_0_0"."entity_uuid", ($4::text), "entity_temporal_metadata_0_0_0"."draft_id"), ($5::text), "entity_temporal_metadata_0_0_0"."entity_edition_id")]::jsonb[])::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/island_exit_switch_int.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/island_exit_switch_int.snap index 264678eb028..98e460d0204 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/island_exit_switch_int.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/island_exit_switch_int.snap @@ -36,4 +36,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> ? { } ==================== Island (entry: bb0, target: postgres) ===================== -CASE WHEN (($7)::int) = 0 THEN (ROW(NULL, 2, ARRAY[]::int[], ARRAY[]::jsonb[])::continuation) WHEN (($7)::int) = 1 THEN (ROW(((1)::boolean), NULL, NULL, NULL)::continuation) END +CASE WHEN ((($10::jsonb))::int) IS NULL THEN (ROW(COALESCE(((FALSE)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($10::jsonb))::int) = 0 THEN (ROW(NULL, 2, ARRAY[]::int[], ARRAY[]::jsonb[])::continuation) WHEN ((($10::jsonb))::int) = 1 THEN (ROW(COALESCE(((1)::boolean), FALSE), NULL, NULL, NULL)::continuation) END diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/island_exit_with_live_out.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/island_exit_with_live_out.snap index 39f3c150454..fa6c2cf2be7 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/island_exit_with_live_out.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/island_exit_with_live_out.snap @@ -30,4 +30,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> ? { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW(NULL, 1, ARRAY[7]::int[], ARRAY[jsonb_build_object($1, jsonb_build_object($2, "entity_temporal_metadata_0_0_0"."web_id", $3, "entity_temporal_metadata_0_0_0"."entity_uuid", $4, "entity_temporal_metadata_0_0_0"."draft_id"), $4, "entity_temporal_metadata_0_0_0"."draft_id")]::jsonb[])::continuation) +(ROW(NULL, 1, ARRAY[7]::int[], ARRAY[jsonb_build_object(($1::text), jsonb_build_object(($2::text), "entity_temporal_metadata_0_0_0"."web_id", ($3::text), "entity_temporal_metadata_0_0_0"."entity_uuid", ($4::text), "entity_temporal_metadata_0_0_0"."draft_id"), ($5::text), "entity_temporal_metadata_0_0_0"."entity_edition_id")]::jsonb[])::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/left_entity_filter.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/left_entity_filter.snap index fe2f8a390d3..7cf393c5dd1 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/left_entity_filter.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/left_entity_filter.snap @@ -19,4 +19,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Boolean { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW((("entity_has_left_entity_0_0_1"."left_entity_uuid" = $1)::boolean), NULL, NULL, NULL)::continuation) +(ROW(COALESCE(((to_jsonb("entity_has_left_entity_0_0_1"."left_entity_uuid") = to_jsonb(($1::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/nested_property_access.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/nested_property_access.snap index f64474ca7a8..7ccf763f0a4 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/nested_property_access.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/nested_property_access.snap @@ -19,4 +19,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Boolean { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW(((jsonb_extract_path("entity_editions_0_0_1"."properties", (($1)::text), (($2)::text)) = $3)::boolean), NULL, NULL, NULL)::continuation) +(ROW(COALESCE(((to_jsonb(jsonb_extract_path("entity_editions_0_0_1"."properties", ((($1::text))::text), ((($2::text))::text))) = to_jsonb(($3::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/property_field_equality.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/property_field_equality.snap index 2de27fe818f..526ca368cb7 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/property_field_equality.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/property_field_equality.snap @@ -19,4 +19,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Boolean { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW(((jsonb_extract_path("entity_editions_0_0_1"."properties", (($1)::text)) = $2)::boolean), NULL, NULL, NULL)::continuation) +(ROW(COALESCE(((to_jsonb(jsonb_extract_path("entity_editions_0_0_1"."properties", ((($1::text))::text))) = to_jsonb(($2::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/property_mask.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/property_mask.snap index cbcf113b00e..ceb2426c40d 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/property_mask.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/property_mask.snap @@ -26,16 +26,16 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> ? { } ===================================== SQL ====================================== -SELECT ("entity_editions_0_0_1"."properties" - $99) AS "properties", jsonb_build_object($3, jsonb_build_object($4, "entity_temporal_metadata_0_0_0"."web_id", $5, "entity_temporal_metadata_0_0_0"."entity_uuid", $6, "entity_temporal_metadata_0_0_0"."draft_id"), $6, "entity_temporal_metadata_0_0_0"."draft_id") AS "record_id", jsonb_build_object($7, "entity_temporal_metadata_0_0_0"."decision_time", $8, "entity_temporal_metadata_0_0_0"."transaction_time") AS "temporal_versioning", "entity_is_of_type_ids_0_0_2"."entity_type_ids" AS "entity_type_ids", "entity_editions_0_0_1"."archived" AS "archived", "entity_editions_0_0_1"."confidence" AS "confidence", "entity_ids_0_0_3"."provenance" AS "provenance_inferred", "entity_editions_0_0_1"."provenance" AS "provenance_edition", ("entity_editions_0_0_1"."property_metadata" - $99) AS "property_metadata", "entity_has_left_entity_0_0_4"."left_web_id" AS "left_entity_web_id", "entity_has_left_entity_0_0_4"."left_entity_uuid" AS "left_entity_uuid", "entity_has_right_entity_0_0_5"."right_web_id" AS "right_entity_web_id", "entity_has_right_entity_0_0_5"."right_entity_uuid" AS "right_entity_uuid", "entity_has_left_entity_0_0_4"."confidence" AS "left_entity_confidence", "entity_has_right_entity_0_0_5"."confidence" AS "right_entity_confidence", "entity_has_left_entity_0_0_4"."provenance" AS "left_entity_provenance", "entity_has_right_entity_0_0_5"."provenance" AS "right_entity_provenance", ("continuation_0_0"."row")."block" AS "continuation_0_0_block", ("continuation_0_0"."row")."locals" AS "continuation_0_0_locals", ("continuation_0_0"."row")."values" AS "continuation_0_0_values" +SELECT ("entity_editions_0_0_1"."properties" - $99) AS "properties", jsonb_build_object(($3::text), jsonb_build_object(($4::text), "entity_temporal_metadata_0_0_0"."web_id", ($5::text), "entity_temporal_metadata_0_0_0"."entity_uuid", ($6::text), "entity_temporal_metadata_0_0_0"."draft_id"), ($7::text), "entity_temporal_metadata_0_0_0"."entity_edition_id") AS "record_id", jsonb_build_object(($8::text), jsonb_build_object(($9::text), (extract(epoch from lower("entity_temporal_metadata_0_0_0"."decision_time")) * 1000)::int8, ($10::text), CASE WHEN upper_inf("entity_temporal_metadata_0_0_0"."decision_time") THEN NULL ELSE (extract(epoch from upper("entity_temporal_metadata_0_0_0"."decision_time")) * 1000)::int8 END), ($11::text), jsonb_build_object(($9::text), (extract(epoch from lower("entity_temporal_metadata_0_0_0"."transaction_time")) * 1000)::int8, ($10::text), CASE WHEN upper_inf("entity_temporal_metadata_0_0_0"."transaction_time") THEN NULL ELSE (extract(epoch from upper("entity_temporal_metadata_0_0_0"."transaction_time")) * 1000)::int8 END)) AS "temporal_versioning", "entity_is_of_type_ids_0_0_2"."entity_type_ids" AS "entity_type_ids", "entity_editions_0_0_1"."archived" AS "archived", "entity_editions_0_0_1"."confidence" AS "confidence", "entity_ids_0_0_3"."provenance" AS "provenance_inferred", "entity_editions_0_0_1"."provenance" AS "provenance_edition", ("entity_editions_0_0_1"."property_metadata" - $99) AS "property_metadata", "entity_has_left_entity_0_0_4"."left_web_id" AS "left_entity_web_id", "entity_has_left_entity_0_0_4"."left_entity_uuid" AS "left_entity_uuid", "entity_has_right_entity_0_0_5"."right_web_id" AS "right_entity_web_id", "entity_has_right_entity_0_0_5"."right_entity_uuid" AS "right_entity_uuid", "entity_has_left_entity_0_0_4"."confidence" AS "left_entity_confidence", "entity_has_right_entity_0_0_5"."confidence" AS "right_entity_confidence", "entity_has_left_entity_0_0_4"."provenance" AS "left_entity_provenance", "entity_has_right_entity_0_0_5"."provenance" AS "right_entity_provenance", ("continuation_0_0"."row")."block" AS "continuation_0_0_block", ("continuation_0_0"."row")."locals" AS "continuation_0_0_locals", ("continuation_0_0"."row")."values" AS "continuation_0_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" INNER JOIN "entity_editions" AS "entity_editions_0_0_1" ON "entity_editions_0_0_1"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" INNER JOIN "entity_ids" AS "entity_ids_0_0_3" ON "entity_ids_0_0_3"."web_id" = "entity_temporal_metadata_0_0_0"."web_id" AND "entity_ids_0_0_3"."entity_uuid" = "entity_temporal_metadata_0_0_0"."entity_uuid" -LEFT OUTER JOIN LATERAL (SELECT jsonb_agg(jsonb_build_object($9, "b", $10, "v")) AS "entity_type_ids" +LEFT OUTER JOIN LATERAL (SELECT jsonb_agg(jsonb_build_object(($12::text), "b", ($13::text), "v")) AS "entity_type_ids" FROM "entity_is_of_type_ids" AS "eit" -CROSS JOIN UNNEST("eit"."base_urls", "eit"."versions") AS "u"("b", "v") +CROSS JOIN UNNEST("eit"."base_urls", ("eit"."versions"::text[])) AS "u"("b", "v") WHERE "eit"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id") AS "entity_is_of_type_ids_0_0_2" ON TRUE LEFT OUTER JOIN "entity_has_left_entity" AS "entity_has_left_entity_0_0_4" @@ -46,7 +46,7 @@ LEFT OUTER JOIN "entity_has_right_entity" AS "entity_has_right_entity_0_0_5" AND "entity_has_right_entity_0_0_5"."entity_uuid" = "entity_temporal_metadata_0_0_0"."entity_uuid" CROSS JOIN LATERAL (SELECT (ROW(NULL, 1, ARRAY[]::int[], ARRAY[]::jsonb[])::continuation) AS "row" OFFSET 0) AS "continuation_0_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_0_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_0_0"."row")."filter" IS NOT FALSE ================================== Parameters ================================== $1: TemporalAxis(Transaction) @@ -55,7 +55,10 @@ $3: Symbol(entity_id) $4: Symbol(web_id) $5: Symbol(entity_uuid) $6: Symbol(draft_id) -$7: Symbol(decision_time) -$8: Symbol(transaction_time) -$9: Symbol(base_url) -$10: Symbol(version) +$7: Symbol(edition_id) +$8: Symbol(decision_time) +$9: Symbol(start) +$10: Symbol(end) +$11: Symbol(transaction_time) +$12: Symbol(base_url) +$13: Symbol(version) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/provides_drives_select_and_joins.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/provides_drives_select_and_joins.snap index 5f16347a12a..0862e7b6116 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/provides_drives_select_and_joins.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/provides_drives_select_and_joins.snap @@ -21,16 +21,16 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> ? { } ===================================== SQL ====================================== -SELECT "entity_editions_0_0_1"."properties" AS "properties", jsonb_build_object($3, jsonb_build_object($4, "entity_temporal_metadata_0_0_0"."web_id", $5, "entity_temporal_metadata_0_0_0"."entity_uuid", $6, "entity_temporal_metadata_0_0_0"."draft_id"), $6, "entity_temporal_metadata_0_0_0"."draft_id") AS "record_id", jsonb_build_object($7, "entity_temporal_metadata_0_0_0"."decision_time", $8, "entity_temporal_metadata_0_0_0"."transaction_time") AS "temporal_versioning", "entity_is_of_type_ids_0_0_2"."entity_type_ids" AS "entity_type_ids", "entity_editions_0_0_1"."archived" AS "archived", "entity_editions_0_0_1"."confidence" AS "confidence", "entity_ids_0_0_3"."provenance" AS "provenance_inferred", "entity_editions_0_0_1"."provenance" AS "provenance_edition", "entity_editions_0_0_1"."property_metadata" AS "property_metadata", "entity_has_left_entity_0_0_4"."left_web_id" AS "left_entity_web_id", "entity_has_left_entity_0_0_4"."left_entity_uuid" AS "left_entity_uuid", "entity_has_right_entity_0_0_5"."right_web_id" AS "right_entity_web_id", "entity_has_right_entity_0_0_5"."right_entity_uuid" AS "right_entity_uuid", "entity_has_left_entity_0_0_4"."confidence" AS "left_entity_confidence", "entity_has_right_entity_0_0_5"."confidence" AS "right_entity_confidence", "entity_has_left_entity_0_0_4"."provenance" AS "left_entity_provenance", "entity_has_right_entity_0_0_5"."provenance" AS "right_entity_provenance" +SELECT "entity_editions_0_0_1"."properties" AS "properties", jsonb_build_object(($3::text), jsonb_build_object(($4::text), "entity_temporal_metadata_0_0_0"."web_id", ($5::text), "entity_temporal_metadata_0_0_0"."entity_uuid", ($6::text), "entity_temporal_metadata_0_0_0"."draft_id"), ($7::text), "entity_temporal_metadata_0_0_0"."entity_edition_id") AS "record_id", jsonb_build_object(($8::text), jsonb_build_object(($9::text), (extract(epoch from lower("entity_temporal_metadata_0_0_0"."decision_time")) * 1000)::int8, ($10::text), CASE WHEN upper_inf("entity_temporal_metadata_0_0_0"."decision_time") THEN NULL ELSE (extract(epoch from upper("entity_temporal_metadata_0_0_0"."decision_time")) * 1000)::int8 END), ($11::text), jsonb_build_object(($9::text), (extract(epoch from lower("entity_temporal_metadata_0_0_0"."transaction_time")) * 1000)::int8, ($10::text), CASE WHEN upper_inf("entity_temporal_metadata_0_0_0"."transaction_time") THEN NULL ELSE (extract(epoch from upper("entity_temporal_metadata_0_0_0"."transaction_time")) * 1000)::int8 END)) AS "temporal_versioning", "entity_is_of_type_ids_0_0_2"."entity_type_ids" AS "entity_type_ids", "entity_editions_0_0_1"."archived" AS "archived", "entity_editions_0_0_1"."confidence" AS "confidence", "entity_ids_0_0_3"."provenance" AS "provenance_inferred", "entity_editions_0_0_1"."provenance" AS "provenance_edition", "entity_editions_0_0_1"."property_metadata" AS "property_metadata", "entity_has_left_entity_0_0_4"."left_web_id" AS "left_entity_web_id", "entity_has_left_entity_0_0_4"."left_entity_uuid" AS "left_entity_uuid", "entity_has_right_entity_0_0_5"."right_web_id" AS "right_entity_web_id", "entity_has_right_entity_0_0_5"."right_entity_uuid" AS "right_entity_uuid", "entity_has_left_entity_0_0_4"."confidence" AS "left_entity_confidence", "entity_has_right_entity_0_0_5"."confidence" AS "right_entity_confidence", "entity_has_left_entity_0_0_4"."provenance" AS "left_entity_provenance", "entity_has_right_entity_0_0_5"."provenance" AS "right_entity_provenance" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" INNER JOIN "entity_editions" AS "entity_editions_0_0_1" ON "entity_editions_0_0_1"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" INNER JOIN "entity_ids" AS "entity_ids_0_0_3" ON "entity_ids_0_0_3"."web_id" = "entity_temporal_metadata_0_0_0"."web_id" AND "entity_ids_0_0_3"."entity_uuid" = "entity_temporal_metadata_0_0_0"."entity_uuid" -LEFT OUTER JOIN LATERAL (SELECT jsonb_agg(jsonb_build_object($9, "b", $10, "v")) AS "entity_type_ids" +LEFT OUTER JOIN LATERAL (SELECT jsonb_agg(jsonb_build_object(($12::text), "b", ($13::text), "v")) AS "entity_type_ids" FROM "entity_is_of_type_ids" AS "eit" -CROSS JOIN UNNEST("eit"."base_urls", "eit"."versions") AS "u"("b", "v") +CROSS JOIN UNNEST("eit"."base_urls", ("eit"."versions"::text[])) AS "u"("b", "v") WHERE "eit"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id") AS "entity_is_of_type_ids_0_0_2" ON TRUE LEFT OUTER JOIN "entity_has_left_entity" AS "entity_has_left_entity_0_0_4" @@ -39,7 +39,7 @@ LEFT OUTER JOIN "entity_has_left_entity" AS "entity_has_left_entity_0_0_4" LEFT OUTER JOIN "entity_has_right_entity" AS "entity_has_right_entity_0_0_5" ON "entity_has_right_entity_0_0_5"."web_id" = "entity_temporal_metadata_0_0_0"."web_id" AND "entity_has_right_entity_0_0_5"."entity_uuid" = "entity_temporal_metadata_0_0_0"."entity_uuid" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) ================================== Parameters ================================== $1: TemporalAxis(Transaction) @@ -48,7 +48,10 @@ $3: Symbol(entity_id) $4: Symbol(web_id) $5: Symbol(entity_uuid) $6: Symbol(draft_id) -$7: Symbol(decision_time) -$8: Symbol(transaction_time) -$9: Symbol(base_url) -$10: Symbol(version) +$7: Symbol(edition_id) +$8: Symbol(decision_time) +$9: Symbol(start) +$10: Symbol(end) +$11: Symbol(transaction_time) +$12: Symbol(base_url) +$13: Symbol(version) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/straight_line_goto_chain.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/straight_line_goto_chain.snap index 81beb0bf677..c2681da472e 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/straight_line_goto_chain.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/straight_line_goto_chain.snap @@ -25,4 +25,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Boolean { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW((($1)::boolean), NULL, NULL, NULL)::continuation) +(ROW(COALESCE(((($1::jsonb))::boolean), FALSE), NULL, NULL, NULL)::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/switch_int_many_branches.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/switch_int_many_branches.snap index f4f8f4d594f..4793b101374 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/switch_int_many_branches.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/switch_int_many_branches.snap @@ -36,4 +36,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Boolean { } ==================== Island (entry: bb0, target: postgres) ===================== -CASE WHEN (($1)::int) = 0 THEN (ROW(((1)::boolean), NULL, NULL, NULL)::continuation) WHEN (($1)::int) = 1 THEN (ROW(((0)::boolean), NULL, NULL, NULL)::continuation) WHEN (($1)::int) = 2 THEN (ROW(((1)::boolean), NULL, NULL, NULL)::continuation) WHEN (($1)::int) = 3 THEN (ROW(((0)::boolean), NULL, NULL, NULL)::continuation) ELSE (ROW(((1)::boolean), NULL, NULL, NULL)::continuation) END +CASE WHEN ((($1::jsonb))::int) IS NULL THEN (ROW(COALESCE(((FALSE)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($1::jsonb))::int) = 0 THEN (ROW(COALESCE(((1)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($1::jsonb))::int) = 1 THEN (ROW(COALESCE(((0)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($1::jsonb))::int) = 2 THEN (ROW(COALESCE(((1)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($1::jsonb))::int) = 3 THEN (ROW(COALESCE(((0)::boolean), FALSE), NULL, NULL, NULL)::continuation) ELSE (ROW(COALESCE(((1)::boolean), FALSE), NULL, NULL, NULL)::continuation) END diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/temporal_decision_time_interval.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/temporal_decision_time_interval.snap new file mode 100644 index 00000000000..c21e275c7bf --- /dev/null +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/temporal_decision_time_interval.snap @@ -0,0 +1,30 @@ +--- +source: libs/@local/hashql/eval/src/postgres/filter/tests.rs +expression: report.to_string() +--- +===================================== MIR ====================================== + +fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> ? { + let %2: ? + let %3: () -> ? + let %4: ? + + bb0(): { + %2 = %1.metadata.temporal_versioning.decision_time + %3 = ({def@99} as FnPtr) + %4 = apply %3 + + return %4 + } +} +===================================== SQL ====================================== + +SELECT jsonb_build_object(($3::text), (extract(epoch from lower("entity_temporal_metadata_0_0_0"."decision_time")) * 1000)::int8, ($4::text), CASE WHEN upper_inf("entity_temporal_metadata_0_0_0"."decision_time") THEN NULL ELSE (extract(epoch from upper("entity_temporal_metadata_0_0_0"."decision_time")) * 1000)::int8 END) AS "decision_time" +FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) +================================== Parameters ================================== + +$1: TemporalAxis(Transaction) +$2: TemporalAxis(Decision) +$3: Symbol(start) +$4: Symbol(end) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/unary_bitnot.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/unary_bitnot.snap index 3d491b28c44..8becc7a6443 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/unary_bitnot.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/unary_bitnot.snap @@ -17,4 +17,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Integer { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW(((~($1))::boolean), NULL, NULL, NULL)::continuation) +(ROW(COALESCE(((~(($1::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/unary_neg.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/unary_neg.snap index 4581def63e3..560171c8349 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/unary_neg.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/unary_neg.snap @@ -17,4 +17,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Integer { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW(((-($1))::boolean), NULL, NULL, NULL)::continuation) +(ROW(COALESCE(((-(($1::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/filter/unary_not.snap b/libs/@local/hashql/eval/tests/ui/postgres/filter/unary_not.snap index 2c36fc3efe3..4bedc2ad845 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/filter/unary_not.snap +++ b/libs/@local/hashql/eval/tests/ui/postgres/filter/unary_not.snap @@ -17,4 +17,4 @@ fn {graph::read::filter@4294967040}(%0: (), %1: Entity) -> Boolean { } ==================== Island (entry: bb0, target: postgres) ===================== -(ROW(((NOT($1))::boolean), NULL, NULL, NULL)::continuation) +(ROW(COALESCE(((NOT(($1::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/if-input-branches.stdout b/libs/@local/hashql/eval/tests/ui/postgres/if-input-branches.stdout index 6c87d1f0131..87a155ae195 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/if-input-branches.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/if-input-branches.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_2_0"."row")."block" AS "continuation_2_0_block", ("continuation_2_0"."row")."locals" AS "continuation_2_0_locals", ("continuation_2_0"."row")."values" AS "continuation_2_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT CASE WHEN (($3)::int) = 0 THEN (ROW((("entity_temporal_metadata_0_0_0"."entity_uuid" = $4)::boolean), NULL, NULL, NULL)::continuation) WHEN (($3)::int) = 1 THEN (ROW((("entity_temporal_metadata_0_0_0"."entity_uuid" = $5)::boolean), NULL, NULL, NULL)::continuation) END AS "row" +CROSS JOIN LATERAL (SELECT CASE WHEN ((($3::jsonb))::int) IS NULL THEN (ROW(COALESCE(((FALSE)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($3::jsonb))::int) = 0 THEN (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."entity_uuid") = to_jsonb(($4::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($3::jsonb))::int) = 1 THEN (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."entity_uuid") = to_jsonb(($5::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) END AS "row" OFFSET 0) AS "continuation_2_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_2_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_2_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/input-parameter-exists.stdout b/libs/@local/hashql/eval/tests/ui/postgres/input-parameter-exists.stdout index 148c2f48505..c486a8c507f 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/input-parameter-exists.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/input-parameter-exists.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_3_0"."row")."block" AS "continuation_3_0_block", ("continuation_3_0"."row")."locals" AS "continuation_3_0_locals", ("continuation_3_0"."row")."values" AS "continuation_3_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT CASE WHEN (($3 IS NOT NULL)::int) = 0 THEN (ROW(((1)::boolean), NULL, NULL, NULL)::continuation) WHEN (($3 IS NOT NULL)::int) = 1 THEN (ROW((($3)::boolean), NULL, NULL, NULL)::continuation) END AS "row" +CROSS JOIN LATERAL (SELECT CASE WHEN ((($3::jsonb) IS NOT NULL)::int) IS NULL THEN (ROW(COALESCE(((FALSE)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($3::jsonb) IS NOT NULL)::int) = 0 THEN (ROW(COALESCE(((1)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($3::jsonb) IS NOT NULL)::int) = 1 THEN (ROW(COALESCE(((($3::jsonb))::boolean), FALSE), NULL, NULL, NULL)::continuation) END AS "row" OFFSET 0) AS "continuation_3_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_3_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_3_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/input-parameter-load.stdout b/libs/@local/hashql/eval/tests/ui/postgres/input-parameter-load.stdout index bd0a1866e48..c4ada3afc65 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/input-parameter-load.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/input-parameter-load.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_2_0"."row")."block" AS "continuation_2_0_block", ("continuation_2_0"."row")."locals" AS "continuation_2_0_locals", ("continuation_2_0"."row")."values" AS "continuation_2_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW((("entity_temporal_metadata_0_0_0"."entity_uuid" = $3)::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."entity_uuid") = to_jsonb(($3::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_2_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_2_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_2_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/let-binding-propagation.stdout b/libs/@local/hashql/eval/tests/ui/postgres/let-binding-propagation.stdout index bd0a1866e48..c4ada3afc65 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/let-binding-propagation.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/let-binding-propagation.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_2_0"."row")."block" AS "continuation_2_0_block", ("continuation_2_0"."row")."locals" AS "continuation_2_0_locals", ("continuation_2_0"."row")."values" AS "continuation_2_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW((("entity_temporal_metadata_0_0_0"."entity_uuid" = $3)::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."entity_uuid") = to_jsonb(($3::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_2_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_2_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_2_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/list-construction.stdout b/libs/@local/hashql/eval/tests/ui/postgres/list-construction.stdout index 6c3997b7dc0..1d748250778 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/list-construction.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/list-construction.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_3_0"."row")."block" AS "continuation_3_0_block", ("continuation_3_0"."row")."locals" AS "continuation_3_0_locals", ("continuation_3_0"."row")."values" AS "continuation_3_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW(((jsonb_build_array("entity_temporal_metadata_0_0_0"."entity_uuid", $3) = jsonb_build_array($4, "entity_temporal_metadata_0_0_0"."entity_uuid"))::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb(jsonb_build_array("entity_temporal_metadata_0_0_0"."entity_uuid", ($3::jsonb))) = to_jsonb(jsonb_build_array(($4::jsonb), "entity_temporal_metadata_0_0_0"."entity_uuid")))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_3_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_3_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_3_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/logical-and-inputs.stdout b/libs/@local/hashql/eval/tests/ui/postgres/logical-and-inputs.stdout index 44e9406d201..294cd646a29 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/logical-and-inputs.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/logical-and-inputs.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_3_0"."row")."block" AS "continuation_3_0_block", ("continuation_3_0"."row")."locals" AS "continuation_3_0_locals", ("continuation_3_0"."row")."values" AS "continuation_3_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT CASE WHEN (($3)::int) = 0 THEN (ROW(((0)::boolean), NULL, NULL, NULL)::continuation) WHEN (($3)::int) = 1 THEN (ROW((($4)::boolean), NULL, NULL, NULL)::continuation) END AS "row" +CROSS JOIN LATERAL (SELECT CASE WHEN ((($3::jsonb))::int) IS NULL THEN (ROW(COALESCE(((FALSE)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($3::jsonb))::int) = 0 THEN (ROW(COALESCE(((0)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($3::jsonb))::int) = 1 THEN (ROW(COALESCE(((($4::jsonb))::boolean), FALSE), NULL, NULL, NULL)::continuation) END AS "row" OFFSET 0) AS "continuation_3_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_3_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_3_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/minimal-select-no-extra-joins.stdout b/libs/@local/hashql/eval/tests/ui/postgres/minimal-select-no-extra-joins.stdout index 976e9859736..e5c6bb7f053 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/minimal-select-no-extra-joins.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/minimal-select-no-extra-joins.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_2_0"."row")."block" AS "continuation_2_0_block", ("continuation_2_0"."row")."locals" AS "continuation_2_0_locals", ("continuation_2_0"."row")."values" AS "continuation_2_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW((("entity_temporal_metadata_0_0_0"."web_id" = $3)::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."web_id") = to_jsonb(($3::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_2_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_2_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_2_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/mixed-sources-filter.stdout b/libs/@local/hashql/eval/tests/ui/postgres/mixed-sources-filter.stdout index 75feded7550..8a609b8857f 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/mixed-sources-filter.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/mixed-sources-filter.stdout @@ -4,14 +4,14 @@ SELECT ("continuation_0_0"."row")."block" AS "continuation_0_0_block", ("continu FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" INNER JOIN "entity_editions" AS "entity_editions_0_0_1" ON "entity_editions_0_0_1"."entity_edition_id" = "entity_temporal_metadata_0_0_0"."entity_edition_id" -CROSS JOIN LATERAL (SELECT (ROW(((NOT("entity_editions_0_0_1"."archived"))::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((NOT("entity_editions_0_0_1"."archived"))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_0_0" -CROSS JOIN LATERAL (SELECT (ROW((("entity_temporal_metadata_0_0_0"."entity_uuid" = $3)::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."entity_uuid") = to_jsonb(($3::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_1_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_0_0"."row")."filter" IS NOT FALSE AND ("continuation_1_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_0_0"."row")."filter" IS NOT FALSE AND ("continuation_1_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ $1: TemporalAxis(Transaction) $2: TemporalAxis(Decision) -$3: Env(1, #0) +$3: Env(%4, #0) diff --git a/libs/@local/hashql/eval/tests/ui/postgres/multiple-filters.stdout b/libs/@local/hashql/eval/tests/ui/postgres/multiple-filters.stdout index 528d2e7d33b..10036eef5ac 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/multiple-filters.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/multiple-filters.stdout @@ -2,11 +2,11 @@ SELECT ("continuation_3_0"."row")."block" AS "continuation_3_0_block", ("continuation_3_0"."row")."locals" AS "continuation_3_0_locals", ("continuation_3_0"."row")."values" AS "continuation_3_0_values", ("continuation_4_0"."row")."block" AS "continuation_4_0_block", ("continuation_4_0"."row")."locals" AS "continuation_4_0_locals", ("continuation_4_0"."row")."values" AS "continuation_4_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW((("entity_temporal_metadata_0_0_0"."entity_uuid" = $3)::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."entity_uuid") = to_jsonb(($3::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_3_0" -CROSS JOIN LATERAL (SELECT (ROW((("entity_temporal_metadata_0_0_0"."web_id" = $4)::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."web_id") = to_jsonb(($4::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_4_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_3_0"."row")."filter" IS NOT FALSE AND ("continuation_4_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_3_0"."row")."filter" IS NOT FALSE AND ("continuation_4_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/nested-if-input-branches.stdout b/libs/@local/hashql/eval/tests/ui/postgres/nested-if-input-branches.stdout index a3e812791fe..acbe1e351eb 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/nested-if-input-branches.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/nested-if-input-branches.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_2_0"."row")."block" AS "continuation_2_0_block", ("continuation_2_0"."row")."locals" AS "continuation_2_0_locals", ("continuation_2_0"."row")."values" AS "continuation_2_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT CASE WHEN (($3)::int) = 0 THEN (ROW((("entity_temporal_metadata_0_0_0"."entity_uuid" = $4)::boolean), NULL, NULL, NULL)::continuation) WHEN (($3)::int) = 1 THEN CASE WHEN (($5)::int) = 0 THEN (ROW((("entity_temporal_metadata_0_0_0"."entity_uuid" = $6)::boolean), NULL, NULL, NULL)::continuation) WHEN (($5)::int) = 1 THEN (ROW((("entity_temporal_metadata_0_0_0"."entity_uuid" = $7)::boolean), NULL, NULL, NULL)::continuation) END END AS "row" +CROSS JOIN LATERAL (SELECT CASE WHEN ((($3::jsonb))::int) IS NULL THEN (ROW(COALESCE(((FALSE)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($3::jsonb))::int) = 0 THEN (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."entity_uuid") = to_jsonb(($4::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($3::jsonb))::int) = 1 THEN CASE WHEN ((($5::jsonb))::int) IS NULL THEN (ROW(COALESCE(((FALSE)::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($5::jsonb))::int) = 0 THEN (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."entity_uuid") = to_jsonb(($6::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) WHEN ((($5::jsonb))::int) = 1 THEN (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."entity_uuid") = to_jsonb(($7::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) END END AS "row" OFFSET 0) AS "continuation_2_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_2_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_2_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/opaque-passthrough.stdout b/libs/@local/hashql/eval/tests/ui/postgres/opaque-passthrough.stdout index b16f6bacefd..d53b113737a 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/opaque-passthrough.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/opaque-passthrough.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_8_0"."row")."block" AS "continuation_8_0_block", ("continuation_8_0"."row")."locals" AS "continuation_8_0_locals", ("continuation_8_0"."row")."values" AS "continuation_8_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW((("entity_temporal_metadata_0_0_0"."entity_uuid" = $3)::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb("entity_temporal_metadata_0_0_0"."entity_uuid") = to_jsonb(($3::jsonb)))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_8_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_8_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_8_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/struct-construction.stdout b/libs/@local/hashql/eval/tests/ui/postgres/struct-construction.stdout index f630b4d404e..82cb6f0d73f 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/struct-construction.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/struct-construction.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_4_0"."row")."block" AS "continuation_4_0_block", ("continuation_4_0"."row")."locals" AS "continuation_4_0_locals", ("continuation_4_0"."row")."values" AS "continuation_4_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW(((jsonb_build_object($3, "entity_temporal_metadata_0_0_0"."entity_uuid", $4, "entity_temporal_metadata_0_0_0"."web_id") = jsonb_build_object($3, $5, $4, $6))::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb(jsonb_build_object(($3::text), "entity_temporal_metadata_0_0_0"."entity_uuid", ($4::text), "entity_temporal_metadata_0_0_0"."web_id")) = to_jsonb(jsonb_build_object(($3::text), ($5::jsonb), ($4::text), ($6::jsonb))))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_4_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_4_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_4_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/eval/tests/ui/postgres/tuple-construction.stdout b/libs/@local/hashql/eval/tests/ui/postgres/tuple-construction.stdout index 9846897911e..545cec8b6a8 100644 --- a/libs/@local/hashql/eval/tests/ui/postgres/tuple-construction.stdout +++ b/libs/@local/hashql/eval/tests/ui/postgres/tuple-construction.stdout @@ -2,9 +2,9 @@ SELECT ("continuation_4_0"."row")."block" AS "continuation_4_0_block", ("continuation_4_0"."row")."locals" AS "continuation_4_0_locals", ("continuation_4_0"."row")."values" AS "continuation_4_0_values" FROM "entity_temporal_metadata" AS "entity_temporal_metadata_0_0_0" -CROSS JOIN LATERAL (SELECT (ROW(((jsonb_build_array("entity_temporal_metadata_0_0_0"."entity_uuid", "entity_temporal_metadata_0_0_0"."web_id") = jsonb_build_array($3, $4))::boolean), NULL, NULL, NULL)::continuation) AS "row" +CROSS JOIN LATERAL (SELECT (ROW(COALESCE(((to_jsonb(jsonb_build_array("entity_temporal_metadata_0_0_0"."entity_uuid", "entity_temporal_metadata_0_0_0"."web_id")) = to_jsonb(jsonb_build_array(($3::jsonb), ($4::jsonb))))::boolean), FALSE), NULL, NULL, NULL)::continuation) AS "row" OFFSET 0) AS "continuation_4_0" -WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && $1 AND "entity_temporal_metadata_0_0_0"."decision_time" && $2 AND ("continuation_4_0"."row")."filter" IS NOT FALSE +WHERE "entity_temporal_metadata_0_0_0"."transaction_time" && ($1::tstzrange) AND "entity_temporal_metadata_0_0_0"."decision_time" && ($2::tstzrange) AND ("continuation_4_0"."row")."filter" IS NOT FALSE ════ Parameters ════════════════════════════════════════════════════════════════ diff --git a/libs/@local/hashql/hir/docs/dependency-diagram.mmd b/libs/@local/hashql/hir/docs/dependency-diagram.mmd index cbc9b89ff6f..979a0dc9dc4 100644 --- a/libs/@local/hashql/hir/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/hir/docs/dependency-diagram.mmd @@ -68,7 +68,6 @@ graph TD 15 -.-> 16 16 --> 19 16 --> 23 - 16 --> 31 17 --> 2 17 --> 18 17 --> 21 diff --git a/libs/@local/hashql/mir/docs/dependency-diagram.mmd b/libs/@local/hashql/mir/docs/dependency-diagram.mmd index af09d1d03f2..58b887e6182 100644 --- a/libs/@local/hashql/mir/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/mir/docs/dependency-diagram.mmd @@ -68,7 +68,6 @@ graph TD 15 -.-> 16 16 --> 19 16 --> 23 - 16 --> 31 17 --> 2 17 --> 18 17 --> 21 diff --git a/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd b/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd index e63e877974f..d4de1967175 100644 --- a/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd +++ b/libs/@local/hashql/syntax-jexpr/docs/dependency-diagram.mmd @@ -68,7 +68,6 @@ graph TD 15 -.-> 16 16 --> 19 16 --> 23 - 16 --> 31 17 --> 2 17 --> 18 17 --> 21 diff --git a/libs/@local/telemetry/docs/dependency-diagram.mmd b/libs/@local/telemetry/docs/dependency-diagram.mmd index b781434d24e..42bf020869c 100644 --- a/libs/@local/telemetry/docs/dependency-diagram.mmd +++ b/libs/@local/telemetry/docs/dependency-diagram.mmd @@ -32,7 +32,6 @@ graph TD 4 -.-> 5 5 --> 6 5 --> 9 - 5 --> 12 6 --> 3 6 --> 8 7 -.-> 5 diff --git a/libs/@local/temporal-client/docs/dependency-diagram.mmd b/libs/@local/temporal-client/docs/dependency-diagram.mmd index 8ff26bd1573..2550d3da4f3 100644 --- a/libs/@local/temporal-client/docs/dependency-diagram.mmd +++ b/libs/@local/temporal-client/docs/dependency-diagram.mmd @@ -61,7 +61,6 @@ graph TD 15 -.-> 16 16 --> 17 16 --> 20 - 16 --> 22 17 --> 6 17 --> 19 18 -.-> 16 diff --git a/tests/graph/test-data/rust/docs/dependency-diagram.mmd b/tests/graph/test-data/rust/docs/dependency-diagram.mmd index d2f3fa16477..2ac8e9e7481 100644 --- a/tests/graph/test-data/rust/docs/dependency-diagram.mmd +++ b/tests/graph/test-data/rust/docs/dependency-diagram.mmd @@ -61,7 +61,6 @@ graph TD 15 -.-> 16 16 --> 17 16 --> 20 - 16 --> 22 17 --> 6 17 --> 19 18 -.-> 16 diff --git a/yarn.lock b/yarn.lock index 108dfaa45ba..b9543f0d55f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15694,8 +15694,11 @@ __metadata: resolution: "@rust/hashql-eval@workspace:libs/@local/hashql/eval" dependencies: "@blockprotocol/type-system-rs": "workspace:*" + "@rust/error-stack": "workspace:*" + "@rust/hash-graph-authorization": "workspace:*" "@rust/hash-graph-postgres-store": "workspace:*" "@rust/hash-graph-store": "workspace:*" + "@rust/hash-graph-test-data": "workspace:*" "@rust/hashql-core": "workspace:*" "@rust/hashql-diagnostics": "workspace:*" "@rust/hashql-hir": "workspace:*"