use powdb_query::executor::Engine;
use powdb_query::result::{QueryError, QueryResult};
use powdb_storage::types::Value;
fn temp_dir(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"powdb_jsonpath_{name}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
fn exec(engine: &mut Engine, q: &str) -> QueryResult {
engine
.execute_powql(q)
.unwrap_or_else(|e| panic!("failed `{q}`: {e}"))
}
fn rows(engine: &mut Engine, q: &str) -> Vec<Vec<Value>> {
match exec(engine, q) {
QueryResult::Rows { rows, .. } => rows,
other => panic!("expected rows for `{q}`, got {other:?}"),
}
}
fn count(engine: &mut Engine, q: &str) -> usize {
rows(engine, q).len()
}
fn scalar(engine: &mut Engine, q: &str) -> Value {
match exec(engine, q) {
QueryResult::Scalar(v) => v,
other => panic!("expected a scalar for `{q}`, got {other:?}"),
}
}
fn engine_with_posts(name: &str) -> Engine {
let mut engine = Engine::new(&temp_dir(name)).unwrap();
exec(&mut engine, "type Post { required id: int, data: json }");
engine
}
fn insert(engine: &mut Engine, id: i64, json: &str) {
let escaped = json.replace('\\', "\\\\").replace('"', "\\\"");
exec(
engine,
&format!(r#"insert Post {{ id := {id}, data := "{escaped}" }}"#),
);
}
#[test]
fn arrow_binds_tighter_than_comparison_and_arithmetic() {
let mut e = engine_with_posts("prec");
insert(&mut e, 1, r#"{"age":21,"n":[10,20]}"#);
assert_eq!(
rows(&mut e, "Post filter .data->age > 20 { .id }"),
vec![vec![Value::Int(1)]]
);
assert_eq!(
rows(&mut e, "Post filter .data->age - 1 = 20 { .id }"),
vec![vec![Value::Int(1)]]
);
assert_eq!(
rows(&mut e, "Post filter .data->n->1 = 20 { .id }"),
vec![vec![Value::Int(1)]]
);
insert(&mut e, 2, r#"{"age":5,"n":[1,2]}"#);
let r = rows(
&mut e,
"Post filter .data->n->0 = 10 and .data->age > 2 { .id }",
);
assert_eq!(r, vec![vec![Value::Int(1)]]);
}
#[test]
fn dash_versus_arrow_lexing() {
let mut e = engine_with_posts("dash");
insert(&mut e, 1, r#"{"a":[100,200]}"#);
insert(&mut e, 2, r#"{"a":[9,9],"n":5}"#);
assert_eq!(
rows(&mut e, "Post filter .data->a->1 = 200 { .id }"),
vec![vec![Value::Int(1)]]
);
let r = rows(&mut e, "Post filter .data->n - 1 = 4 { .id }");
assert_eq!(r, vec![vec![Value::Int(2)]], "` - ` lexes as minus");
}
#[test]
fn string_form_keys_quotes_unicode_and_empty() {
let mut e = engine_with_posts("strkey");
insert(&mut e, 1, r#"{"weird key!":1,"":2,"é":3,"a\"b":4}"#);
assert_eq!(
rows(&mut e, r#"Post filter .data->"weird key!" = 1 { .id }"#),
vec![vec![Value::Int(1)]],
"spaced/punctuated string key"
);
assert_eq!(
rows(&mut e, r#"Post filter .data->"" = 2 { .id }"#),
vec![vec![Value::Int(1)]],
"empty-string key"
);
assert_eq!(
rows(&mut e, "Post filter .data->\"é\" = 3 { .id }"),
vec![vec![Value::Int(1)]],
"unicode key"
);
assert_eq!(
rows(&mut e, r#"Post filter .data->"a\"b" = 4 { .id }"#),
vec![vec![Value::Int(1)]],
"key containing an escaped quote"
);
}
#[test]
fn scalarization_matrix_through_project_and_filter() {
let mut e = engine_with_posts("scalar");
insert(
&mut e,
1,
r#"{"s":"hi","i":7,"f":1.5,"bt":true,"bf":false,"nul":null,"arr":[1],"obj":{"k":1}}"#,
);
let r = rows(
&mut e,
"Post filter .id = 1 { \
s: .data->s, i: .data->i, f: .data->f, bt: .data->bt, bf: .data->bf, \
nul: .data->nul, arr: .data->arr, obj: .data->obj, miss: .data->nope }",
);
assert_eq!(r[0][0], Value::Str("hi".into()));
assert_eq!(r[0][1], Value::Int(7));
assert_eq!(r[0][2], Value::Float(1.5));
assert_eq!(r[0][3], Value::Bool(true));
assert_eq!(r[0][4], Value::Bool(false));
assert_eq!(r[0][5], Value::Empty, "JSON null -> Empty");
assert!(matches!(r[0][6], Value::Json(_)), "array -> Json subdoc");
assert!(matches!(r[0][7], Value::Json(_)), "object -> Json subdoc");
assert_eq!(r[0][8], Value::Empty, "missing -> Empty");
}
#[test]
fn no_implicit_cross_type_coercion() {
let mut e = engine_with_posts("nocoerce");
insert(&mut e, 1, r#"{"age":21}"#);
insert(&mut e, 2, r#"{"age":21.0}"#);
assert_eq!(
count(&mut e, r#"Post filter .data->age = "21""#),
0,
"number node != Str literal"
);
assert_eq!(
count(&mut e, "Post filter .data->age = 21"),
1,
"typed equality: Float(21.0) does NOT equal Int literal 21"
);
assert_eq!(
count(&mut e, "Post filter .data->age > 20"),
2,
"range comparison is numeric across Int/Float"
);
}
#[test]
fn same_path_different_literal_shares_plan_and_stays_correct() {
let mut e = engine_with_posts("samepath");
insert(&mut e, 1, r#"{"age":30}"#);
insert(&mut e, 2, r#"{"age":18}"#);
assert_eq!(
rows(&mut e, "Post filter .data->age = 30 { .id }"),
vec![vec![Value::Int(1)]]
);
assert_eq!(
rows(&mut e, "Post filter .data->age = 18 { .id }"),
vec![vec![Value::Int(2)]]
);
}
#[test]
fn different_paths_same_shape_do_not_collide() {
let mut e = engine_with_posts("diffpath");
insert(&mut e, 1, r#"{"age":30,"yrs":99}"#);
insert(&mut e, 2, r#"{"age":99,"yrs":30}"#);
assert_eq!(
rows(&mut e, "Post filter .data->age = 30 { .id }"),
vec![vec![Value::Int(1)]]
);
assert_eq!(
rows(&mut e, "Post filter .data->yrs = 30 { .id }"),
vec![vec![Value::Int(2)]]
);
insert(&mut e, 3, r#"{"t":[5,6]}"#);
assert_eq!(count(&mut e, "Post filter .data->t->0 = 5 { .id }"), 1);
assert_eq!(count(&mut e, "Post filter .data->t->1 = 5 { .id }"), 0);
}
#[test]
fn alternating_literals_do_not_drift_over_repeats() {
let mut e = engine_with_posts("drift");
insert(&mut e, 1, r#"{"age":30}"#);
insert(&mut e, 2, r#"{"age":18}"#);
for _ in 0..100 {
assert_eq!(
rows(&mut e, "Post filter .data->age = 30 { .id }"),
vec![vec![Value::Int(1)]]
);
assert_eq!(
rows(&mut e, "Post filter .data->age = 18 { .id }"),
vec![vec![Value::Int(2)]]
);
}
}
#[test]
fn prepared_path_query_round_trips() {
use powdb_query::ast::Literal;
let mut e = engine_with_posts("prep");
insert(&mut e, 1, r#"{"age":30}"#);
insert(&mut e, 2, r#"{"age":18}"#);
let prep = e
.prepare("Post filter .data->age = 30 { .id }")
.expect("prepare path filter");
let got = match e
.execute_prepared(&prep, &[Literal::Int(18)])
.expect("execute_prepared")
{
QueryResult::Rows { rows, .. } => rows,
other => panic!("expected rows, got {other:?}"),
};
assert_eq!(
got,
vec![vec![Value::Int(2)]],
"rebinding the literal reaches row 2"
);
}
#[test]
fn path_into_spilled_document() {
let mut e = engine_with_posts("spill");
let blob = "z".repeat(6000);
insert(
&mut e,
1,
&format!(r#"{{"author":"amy","blob":"{blob}","n":9}}"#),
);
let r = rows(
&mut e,
r#"Post filter .data->author = "amy" { .id, n: .data->n }"#,
);
assert_eq!(r, vec![vec![Value::Int(1), Value::Int(9)]]);
match &rows(&mut e, "Post filter .id = 1 { b: .data->blob }")[0][0] {
Value::Str(s) => assert_eq!(s.len(), 6000),
other => panic!("expected Str, got {other:?}"),
}
}
#[test]
fn update_flips_inline_spilled_inline_and_path_finds_it_each_step() {
let mut e = engine_with_posts("flip");
insert(&mut e, 1, r#"{"k":"small","tag":1}"#);
assert_eq!(
rows(&mut e, "Post filter .data->tag = 1 { s: .data->k }"),
vec![vec![Value::Str("small".into())]]
);
let big = "b".repeat(6000);
exec(
&mut e,
&format!(r#"Post filter .id = 1 update {{ data := "{{\"k\":\"{big}\",\"tag\":2}}" }}"#),
);
assert_eq!(
count(&mut e, "Post filter .data->tag = 2 { .id }"),
1,
"found while spilled"
);
match &rows(&mut e, "Post filter .id = 1 { s: .data->k }")[0][0] {
Value::Str(s) => assert_eq!(s.len(), 6000, "spilled value reassembles"),
other => panic!("expected Str, got {other:?}"),
}
exec(
&mut e,
r#"Post filter .id = 1 update { data := "{\"k\":\"tiny\",\"tag\":3}" }"#,
);
assert_eq!(
rows(&mut e, "Post filter .data->tag = 3 { s: .data->k }"),
vec![vec![Value::Str("tiny".into())]],
"found again once inline"
);
assert_eq!(count(&mut e, "Post filter .data->tag = 1"), 0);
assert_eq!(count(&mut e, "Post filter .data->tag = 2"), 0);
}
#[test]
fn crash_recovery_then_path_query() {
let dir = temp_dir("recover");
{
let mut e = Engine::new(&dir).unwrap();
exec(&mut e, "type Post { required id: int, data: json }");
insert(&mut e, 1, r#"{"author":"ida","age":40}"#);
let spilled = "s".repeat(6000);
insert(
&mut e,
2,
&format!(r#"{{"author":"jon","blob":"{spilled}"}}"#),
);
}
let mut e2 = Engine::new(&dir).unwrap();
assert_eq!(
rows(
&mut e2,
r#"Post filter .data->author = "ida" { age: .data->age }"#
),
vec![vec![Value::Int(40)]],
"inline doc recovered and path-queryable"
);
match &rows(&mut e2, "Post filter .id = 2 { b: .data->blob }")[0][0] {
Value::Str(s) => assert_eq!(s.len(), 6000, "spilled doc recovered whole"),
other => panic!("expected Str, got {other:?}"),
}
}
#[test]
fn invalid_json_insert_is_typed_error_and_message_survives() {
let mut e = engine_with_posts("badjson");
let err = e
.execute_powql(r#"insert Post { id := 1, data := "{oops" }"#)
.unwrap_err();
let msg = match err {
QueryError::TypeError(m) | QueryError::Execution(m) => m,
other => panic!("expected a coercion error, got {other:?}"),
};
assert!(
msg.starts_with("invalid JSON"),
"safe wire prefix survives: {msg}"
);
assert_eq!(count(&mut e, "Post filter .id = 1"), 0);
}
#[test]
fn required_json_column_rejects_missing_value() {
let mut e = Engine::new(&temp_dir("required")).unwrap();
exec(&mut e, "type Doc { required id: int, required body: json }");
exec(&mut e, r#"insert Doc { id := 1, body := "{\"ok\":true}" }"#);
let err = e.execute_powql("insert Doc { id := 2 }").unwrap_err();
assert!(
matches!(err, QueryError::TypeError(_) | QueryError::Execution(_)),
"missing required json -> typed error, got {err:?}"
);
assert_eq!(
count(&mut e, "Doc { .id }"),
1,
"only the valid row persisted"
);
}
#[test]
fn group_by_json_column_uses_byte_equality() {
let mut e = engine_with_posts("group");
insert(&mut e, 1, r#"{"a":1,"b":2}"#);
insert(&mut e, 2, r#"{"b":2,"a":1}"#); insert(&mut e, 3, r#"1"#);
insert(&mut e, 4, r#"1.0"#); let groups = rows(&mut e, "Post group .data { .data, n: count(.id) }");
assert_eq!(
groups.len(),
3,
"byte-equal object rows collapse; 1 and 1.0 do not"
);
let counts: Vec<i64> = groups
.iter()
.map(|g| match g[1] {
Value::Int(n) => n,
ref other => panic!("count not int: {other:?}"),
})
.collect();
let mut sorted = counts.clone();
sorted.sort_unstable();
assert_eq!(
sorted,
vec![1, 1, 2],
"the object group has 2, the two numbers 1 each"
);
assert_eq!(
scalar(&mut e, "count(distinct Post { .data })"),
Value::Int(3),
"3 distinct json documents by bytes"
);
}
#[test]
fn order_by_json_column_follows_the_pj1_total_order() {
let mut e = engine_with_posts("orderladder");
insert(&mut e, 1, r#"{}"#); insert(&mut e, 2, r#"[1]"#); insert(&mut e, 3, r#""s""#); insert(&mut e, 4, r#"5"#); insert(&mut e, 5, r#"true"#); insert(&mut e, 6, r#"false"#); insert(&mut e, 7, r#"null"#); let ordered: Vec<i64> = rows(&mut e, "Post order .data { .id }")
.into_iter()
.map(|r| match r[0] {
Value::Int(n) => n,
ref o => panic!("id not int: {o:?}"),
})
.collect();
assert_eq!(
ordered,
vec![7, 6, 5, 4, 3, 2, 1],
"null < false < true < number < string < array < object"
);
}
#[test]
fn path_expressions_work_in_order_group_and_aggregate_positions() {
let mut e = engine_with_posts("pathkey");
insert(&mut e, 1, r#"{"age":30,"kind":"a"}"#);
insert(&mut e, 2, r#"{"age":18,"kind":"b"}"#);
insert(&mut e, 3, r#"{"age":12,"kind":"a"}"#);
assert_eq!(
rows(&mut e, "Post order .data->age { .id }"),
vec![
vec![Value::Int(3)],
vec![Value::Int(2)],
vec![Value::Int(1)]
]
);
assert_eq!(
rows(
&mut e,
"Post group .data->kind { kind: .data->kind, total: sum(.data->age) }",
),
vec![
vec![Value::Str("a".into()), Value::Int(42)],
vec![Value::Str("b".into()), Value::Int(18)]
]
);
assert_eq!(scalar(&mut e, "sum(Post { .data->age })"), Value::Int(60));
}
#[test]
fn grouped_order_by_path_rebinds_through_projection_alias() {
let mut e = engine_with_posts("grouped_path_order");
insert(&mut e, 1, r#"{"kind":"z"}"#);
insert(&mut e, 2, r#"{"kind":"a"}"#);
insert(&mut e, 3, r#"{"kind":"z"}"#);
assert_eq!(
rows(
&mut e,
"Post group .data->kind order .data->kind { kind: .data->kind, n: count(*) }",
),
vec![
vec![Value::Str("a".into()), Value::Int(1)],
vec![Value::Str("z".into()), Value::Int(2)],
]
);
}
#[test]
fn qualified_joined_paths_work_in_filter_group_and_aggregate_positions() {
let mut e = engine_with_posts("qualified_joined_paths");
exec(&mut e, "type Meta { required post_id: int, data: json }");
insert(&mut e, 1, r#"{"kind":"a"}"#);
insert(&mut e, 2, r#"{"kind":"b"}"#);
exec(
&mut e,
r#"insert Meta { post_id := 1, data := "{\"kind\":\"a\",\"value\":7}" }"#,
);
exec(
&mut e,
r#"insert Meta { post_id := 2, data := "{\"kind\":\"wrong\",\"value\":11}" }"#,
);
assert_eq!(
rows(
&mut e,
"Post as p inner join Meta as m on p.id = m.post_id \
filter p.data->kind = m.data->kind \
group p.data->kind { kind: p.data->kind, total: sum(m.data->value) }",
),
vec![vec![Value::Str("a".into()), Value::Int(7)]],
);
}
#[test]
fn window_partition_and_order_accept_json_paths() {
let mut e = engine_with_posts("window_paths");
insert(&mut e, 1, r#"{"kind":"a","score":10}"#);
insert(&mut e, 2, r#"{"kind":"a","score":20}"#);
insert(&mut e, 3, r#"{"kind":"b","score":15}"#);
assert_eq!(
rows(
&mut e,
"Post order .id { .id, rn: row_number() over \
(partition .data->kind order .data->score desc) }",
),
vec![
vec![Value::Int(1), Value::Int(2)],
vec![Value::Int(2), Value::Int(1)],
vec![Value::Int(3), Value::Int(1)],
],
);
}
#[test]
fn non_json_path_bases_are_rejected_in_all_new_expression_slots() {
let mut e = Engine::new(&temp_dir("non_json_expression_slots")).unwrap();
exec(&mut e, "type Metric { age: int }");
exec(&mut e, "insert Metric { age := 7 }");
for query in [
"Metric order .age->x",
"Metric group .age->x { .age->x }",
"sum(Metric { .age->x })",
"Metric group .age { total: sum(.age->x) }",
"Metric { n: row_number() over (partition .age->x) }",
] {
let err = e.execute_powql(query).expect_err(query);
assert!(
err.to_string().contains("not json"),
"`{query}` should reject non-json path base, got {err}"
);
}
}
#[test]
fn stray_aggregates_are_rejected_in_every_expression_slot() {
let mut e = Engine::new(&temp_dir("stray_aggregate_slots")).unwrap();
exec(&mut e, "type Metric { age: int }");
exec(&mut e, "insert Metric { age := 7 }");
for query in [
"Metric order sum(.age)",
"Metric group sum(.age) { .age }",
"sum(Metric { sum(.age) })",
"Metric { n: row_number() over (partition sum(.age)) }",
"Metric as a join Metric as b on sum(a.age) = b.age",
] {
let err = e.execute_powql(query).expect_err(query);
assert!(
err.to_string()
.contains("aggregate function in an unsupported position"),
"`{query}` should reject stray aggregate, got {err}"
);
}
}
#[test]
fn limit_offset_fast_path_over_json_table() {
let mut e = engine_with_posts("limit");
for id in 1..=5 {
insert(&mut e, id, &format!(r#"{{"v":{id}}}"#));
}
let r = rows(
&mut e,
"Post order .id { .id, v: .data->v } limit 2 offset 1",
);
assert_eq!(
r,
vec![
vec![Value::Int(2), Value::Int(2)],
vec![Value::Int(3), Value::Int(3)],
],
"limit/offset applied after path projection"
);
}
#[test]
fn json_type_matrix_including_missing_and_non_json_base() {
let mut e = engine_with_posts("jtype");
insert(
&mut e,
1,
r#"{"nul":null,"s":"x","i":1,"f":1.5,"bt":true,"arr":[1],"obj":{}}"#,
);
let r = rows(
&mut e,
"Post filter .id = 1 { \
nul: json_type(.data->nul), s: json_type(.data->s), i: json_type(.data->i), \
f: json_type(.data->f), bt: json_type(.data->bt), arr: json_type(.data->arr), \
obj: json_type(.data->obj), miss: json_type(.data->nope) }",
);
assert_eq!(r[0][0], Value::Str("null".into()), "present null");
assert_eq!(r[0][1], Value::Str("string".into()));
assert_eq!(r[0][2], Value::Str("number".into()), "integral is 'number'");
assert_eq!(r[0][3], Value::Str("number".into()), "float is 'number'");
assert_eq!(r[0][4], Value::Str("bool".into()));
assert_eq!(r[0][5], Value::Str("array".into()));
assert_eq!(r[0][6], Value::Str("object".into()));
assert_eq!(
r[0][7],
Value::Empty,
"missing path -> Empty, distinct from 'null'"
);
let t = rows(&mut e, "Post filter .id = 1 { t: json_type(.id) }");
assert_eq!(
t[0][0],
Value::Empty,
"json_type(non-json column) yields Empty (see finding: should arguably error)"
);
assert_eq!(count(&mut e, "Post filter json_type(.id) = \"number\""), 0);
}