Skip to content

Commit c021584

Browse files
committed
feat: orchestrator test harness
1 parent 8c96ad0 commit c021584

36 files changed

Lines changed: 481 additions & 63 deletions

Cargo.lock

Lines changed: 10 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

libs/@local/graph/postgres-store/src/store/postgres/query/expression/conditional.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ pub enum Function {
3939
/// values unchanged; wraps text, uuid, integer, boolean, etc. as jsonb
4040
/// scalars.
4141
ToJson(Box<Expression>),
42+
/// Returns the first non-NULL argument.
43+
///
44+
/// Transpiles to `COALESCE(expr, fallback)`.
45+
Coalesce(Box<Expression>, Box<Expression>),
4246
Lower(Box<Expression>),
4347
Upper(Box<Expression>),
4448
LowerInc(Box<Expression>),
@@ -137,6 +141,13 @@ impl Transpile for Function {
137141
expression.transpile(fmt)?;
138142
fmt.write_char(')')
139143
}
144+
Self::Coalesce(expression, fallback) => {
145+
fmt.write_str("COALESCE(")?;
146+
expression.transpile(fmt)?;
147+
fmt.write_str(", ")?;
148+
fallback.transpile(fmt)?;
149+
fmt.write_char(')')
150+
}
140151
Self::Lower(expression) => {
141152
fmt.write_str("lower(")?;
142153
expression.transpile(fmt)?;
@@ -602,6 +613,11 @@ impl Expression {
602613
Self::Grouped(Box::new(self))
603614
}
604615

616+
#[must_use]
617+
pub fn coalesce(self, fallback: Self) -> Self {
618+
Self::Function(Function::Coalesce(Box::new(self), Box::new(fallback)))
619+
}
620+
605621
#[must_use]
606622
pub fn starts_with(lhs: Self, rhs: Self) -> Self {
607623
Self::StartsWith(Box::new(lhs), Box::new(rhs))

libs/@local/hashql/eval/spec/orchestrator-test-plan.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,22 @@
55
| ID | Name | Status | File |
66
| --- | --------------------------------------- | ------ | ---- |
77
| 1.1 | Simple entity read, no filter | done | `jsonc/simple-read.jsonc` |
8-
| 1.2 | Property access | skip | needs MIR builder (properties are un-narrowed `?`) |
9-
| 1.3 | Individual metadata leaf fields | skip | needs MIR builder (metadata behind `List`/`Option`) |
8+
| 1.2 | Property access | done | `programmatic/property-access` + `programmatic/property-arithmetic` |
9+
| 1.3 | Individual metadata leaf fields | done | `jsonc/metadata-leaf-fields.jsonc` |
1010
| 1.4 | Equality filter | done | `jsonc/filter-by-uuid.jsonc` |
1111
| 1.5 | Input-driven filter | done | `jsonc/filter-by-entity-id.jsonc` |
1212
| 1.6 | Link entity read | done | `jsonc/has-link-data.jsonc` |
1313
| 1.7 | Non-link entity (null link_data) | done | `jsonc/null-link-data.jsonc` |
14-
| 1.8 | Organization type | skip | needs MIR builder (entity_type_ids behind `List`) |
14+
| 1.8 | Organization type | done | `jsonc/organization-type.jsonc` |
1515
| 1.9 | Pinned temporal axis | done | `jsonc/pinned-decision-time.jsonc` |
1616
| 1.A | Inequality filter | done | `jsonc/filter-not-equal.jsonc` |
1717
| 1.B | Let binding propagation | done | `jsonc/let-binding.jsonc` |
1818
| 1.C | Empty result set | done | `jsonc/filter-false.jsonc` |
19-
| 1.D | Diamond CFG in filter | todo | `jsonc/filter-diamond-cfg.jsonc` |
19+
| 1.D | Diamond CFG in filter | done | `jsonc/filter-diamond-cfg.jsonc` |
2020
| 2.1 | Interpreter-only filter | done | `jsonc/filter-by-entity-id.jsonc` (EntityId is not serialization-safe) |
2121
| 2.2 | Postgres-only filter | done | `jsonc/filter-by-uuid.jsonc` (EntityUuid is serialization-safe) |
2222
| 2.3 | Mixed island filter (continuation) | done | `jsonc/filter-diamond-cfg.jsonc` (both EntityUuid and EntityId branches) |
23-
| 2.4 | Multiple sequential filters | todo | `jsonc/filter-sequential.jsonc` |
23+
| 2.4 | Multiple sequential filters | done | `jsonc/filter-sequential.jsonc` |
2424
| 3.1 | Metadata only, no properties | todo | unit test (hydration) |
2525
| 3.2 | Full metadata population | todo | unit test (hydration) |
2626
| 3.3 | Composite path: RecordId | todo | unit test (hydration) |

libs/@local/hashql/eval/src/postgres/filter/mod.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,10 @@ impl From<Continuation> for Expression {
9696
let row = match continuation {
9797
Continuation::Return { filter } => {
9898
vec![
99-
filter.grouped().cast(PostgresType::Boolean),
99+
filter
100+
.grouped()
101+
.cast(PostgresType::Boolean)
102+
.coalesce(Self::Constant(query::Constant::Boolean(false))),
100103
null.clone(),
101104
null.clone(),
102105
null,
@@ -184,7 +187,20 @@ fn finish_switch_int<A: Allocator>(
184187
let discriminant = Box::new(discriminant.grouped().cast(PostgresType::Int));
185188

186189
let mut discriminant = Some(discriminant);
187-
let mut conditions = Vec::with_capacity(targets.values().len());
190+
// +1 for the NULL guard: a NULL discriminant means the computation could
191+
// not be evaluated (e.g. missing JSONB key), so we reject the row.
192+
let mut conditions = Vec::with_capacity(targets.values().len() + 1);
193+
194+
conditions.push((
195+
Expression::Unary(UnaryExpression {
196+
op: UnaryOperator::IsNull,
197+
expr: discriminant.clone().unwrap_or_else(|| unreachable!()),
198+
}),
199+
Continuation::Return {
200+
filter: Expression::Constant(query::Constant::Boolean(false)),
201+
}
202+
.into(),
203+
));
188204

189205
for (index, (&value, then)) in targets.values().iter().zip(branch_results).enumerate() {
190206
let is_last = index == targets.values().len() - 1;

libs/@local/hashql/eval/src/postgres/filter/tests.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use hash_graph_postgres_store::store::postgres::query::{Expression, Transpile as
1515
use hashql_core::{
1616
heap::{Heap, Scratch},
1717
id::Id as _,
18+
module::std_lib::graph::types::knowledge::entity as entity_types,
1819
symbol::sym,
1920
r#type::{TypeBuilder, TypeId, environment::Environment},
2021
};
@@ -562,10 +563,10 @@ fn data_island_provides_without_lateral() {
562563

563564
let callee_id = DefId::new(99);
564565

565-
// Light entity path accesses solver puts everything on Interpreter, creating only a
566+
// Light entity path accesses: solver puts everything on Interpreter, creating only a
566567
// Postgres Data island for the entity columns. No Postgres exec island exists.
567568
let body = body!(interner, env; [graph::read::filter]@0/2 -> ? {
568-
decl env: (), vertex: [Opaque sym::path::Entity; ?],
569+
decl env: (), vertex: (|t| entity_types::types::entity(t, t.unknown(), None)),
569570
uuid: ?, func: [fn() -> ?], result: ?;
570571
@proj v_uuid = vertex.entity_uuid: ?;
571572

@@ -612,7 +613,7 @@ fn provides_drives_select_and_joins() {
612613
// bb0 accesses entity paths (Postgres-origin), then bb1 uses a closure (Interpreter).
613614
// The Postgres island should provide the accessed paths to the Interpreter island.
614615
let body = body!(interner, env; [graph::read::filter]@0/2 -> ? {
615-
decl env: (), vertex: [Opaque sym::path::Entity; ?],
616+
decl env: (), vertex: (|t| entity_types::types::entity(t, t.unknown(), None)),
616617
uuid: ?, archived: ?, func: [fn() -> ?], result: ?;
617618
@proj v_uuid = vertex.entity_uuid: ?,
618619
v_metadata = vertex.metadata: ?,
@@ -743,7 +744,7 @@ fn property_mask() {
743744
// Properties access in bb0 (Postgres Data island) with an apply in bb1 (Interpreter)
744745
// ensures Properties and `PropertyMetadata` appear in the provides set.
745746
let body = body!(interner, env; [graph::read::filter]@0/2 -> ? {
746-
decl env: (), vertex: [Opaque sym::path::Entity; ?],
747+
decl env: (), vertex: (|t| entity_types::types::entity(t, t.unknown(), None)),
747748
props: ?, prop_meta: ?, func: [fn() -> ?], result: ?;
748749
@proj v_props = vertex.properties: ?,
749750
v_meta = vertex.metadata: ?,

libs/@local/hashql/eval/tests/orchestrator/discover.rs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::path::{Path, PathBuf};
22

3-
use hashql_core::heap::Heap;
3+
use hashql_compiletest::pipeline::Pipeline;
44
use hashql_mir::{
55
body::Body,
66
def::{DefId, DefIdVec},
@@ -9,11 +9,12 @@ use hashql_mir::{
99

1010
/// Signature for programmatic test builders.
1111
///
12-
/// Each builder receives a heap and returns the MIR components needed for
13-
/// execution: an interner, the entry definition, and the body set. Inputs
14-
/// are constructed by the test runner from seeded entity data, not by the
15-
/// builder.
16-
pub(crate) type ProgrammaticBuilder = fn(&Heap) -> (Interner<'_>, DefId, DefIdVec<Body<'_>>);
12+
/// Each builder receives the pipeline (providing the heap and the shared type
13+
/// environment) and returns the MIR components needed for execution: an
14+
/// interner, the entry definition, and the body set. Inputs are constructed
15+
/// by the test runner from seeded entity data, not by the builder.
16+
pub(crate) type ProgrammaticBuilder =
17+
for<'heap> fn(&Pipeline<'heap>) -> (Interner<'heap>, DefId, DefIdVec<Body<'heap>>);
1718

1819
/// A discovered test case, either from a `.jsonc` file or a programmatic
1920
/// registration.

libs/@local/hashql/eval/tests/orchestrator/inputs.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ use alloc::alloc::Global;
22

33
use hashql_compiletest::pipeline::Pipeline;
44
use hashql_core::{
5-
heap::Heap, module::std_lib::graph::types::knowledge::entity, symbol::sym, r#type::TypeBuilder,
5+
heap::Heap,
6+
module::std_lib::graph::types::{
7+
knowledge::entity, principal::actor_group::web::types as web_types,
8+
},
9+
symbol::sym,
10+
r#type::TypeBuilder,
611
};
712
use hashql_eval::orchestrator::codec::{Decoder, JsonValueRef};
813
use hashql_mir::{
@@ -249,6 +254,16 @@ pub(crate) fn build_inputs<'heap>(
249254
insert_entity_id(&mut inputs, "friend_link_id", &entities.friend_link);
250255
insert_entity_id(&mut inputs, "draft_alice_id", &entities.draft_alice);
251256

257+
// WebId input (all seeded entities share the same web).
258+
let web_id_type = web_types::web_id(&ty, None);
259+
let web_id_value = decoder
260+
.decode(
261+
web_id_type,
262+
JsonValueRef::String(&entities.alice.web_id.to_string()),
263+
)
264+
.expect("could not decode WebId input");
265+
inputs.insert(heap.intern_symbol("web_id"), web_id_value);
266+
252267
// String inputs for property-based filtering.
253268
let string_type = ty.string();
254269
let alice_name = decoder

libs/@local/hashql/eval/tests/orchestrator/main.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ mod error;
1818
mod execution;
1919
mod inputs;
2020
mod output;
21+
mod programmatic;
2122
mod seed;
2223

2324
use self::{
@@ -46,6 +47,13 @@ async fn setup() -> Result<TestContext, Report<SetupError>> {
4647
.with_name("pgvector/pgvector")
4748
.with_tag("0.8.2-pg18-trixie")
4849
.with_reuse(ReuseDirective::CurrentSession)
50+
.with_cmd([
51+
"postgres",
52+
"-c",
53+
"log_statement=all",
54+
"-c",
55+
"log_destination=stderr",
56+
])
4957
.start()
5058
.await
5159
.change_context(SetupError::Container)?;
@@ -149,8 +157,8 @@ fn run_programmatic_test(
149157
bless: bool,
150158
) -> Result<(), Report<TestError>> {
151159
let heap = Heap::new();
152-
let (interner, entry, mut bodies) = builder(&heap);
153160
let mut pipeline = Pipeline::new(&heap);
161+
let (interner, entry, mut bodies) = builder(&pipeline);
154162

155163
let inputs = build_inputs(
156164
&heap,
@@ -184,7 +192,10 @@ fn run_programmatic_test(
184192
}
185193
}
186194

187-
const PROGRAMMATIC_TESTS: &[(&str, ProgrammaticBuilder)] = &[];
195+
const PROGRAMMATIC_TESTS: &[(&str, ProgrammaticBuilder)] = &[
196+
("property-access", programmatic::property_access),
197+
("property-arithmetic", programmatic::property_arithmetic),
198+
];
188199

189200
fn main() -> Result<(), Report<SetupError>> {
190201
let arguments = libtest_mimic::Arguments::from_args();

0 commit comments

Comments
 (0)