From 1647d40809fa029450f7d37766d007e2f41bc374 Mon Sep 17 00:00:00 2001 From: Dmytro Halichenko Date: Mon, 4 May 2026 06:59:55 -0400 Subject: [PATCH] feat: Top level and/or operators in find --- crates/iwe/CHANGELOG.md | 9 + crates/iwe/help/export/after_help.txt | 2 +- crates/iwe/src/filter_args.rs | 6 +- crates/iwe/tests/cli_filter_test.rs | 37 +++ crates/iwec/CHANGELOG.md | 8 + crates/iwec/src/lib.rs | 4 +- crates/iwes/CHANGELOG.md | 8 + crates/liwe/CHANGELOG.md | 9 + crates/liwe/src/markdown/writer.rs | 4 +- crates/liwe/src/query/builder.rs | 124 +++++----- crates/liwe/src/query/document.rs | 1 - crates/liwe/src/query/eval.rs | 7 - crates/liwe/src/query/filter.rs | 8 +- crates/liwe/src/query/prelude.rs | 4 - crates/liwe/tests/normalization_misc.rs | 18 ++ crates/liwe/tests/query_deserialize.rs | 22 +- crates/liwe/tests/query_filter_expression.rs | 231 ++++++++++++++++++- crates/liwe/tests/query_graph.rs | 24 +- docs/architecture.md | 2 + docs/cli-export.md | 2 +- docs/cli-find.md | 8 +- docs/cli-retrieve.md | 2 +- docs/cli-tree.md | 2 +- docs/query-language.md | 7 +- docs/spec.md | 113 +++++---- 25 files changed, 467 insertions(+), 195 deletions(-) diff --git a/crates/iwe/CHANGELOG.md b/crates/iwe/CHANGELOG.md index c1660db..1a0d1e4 100644 --- a/crates/iwe/CHANGELOG.md +++ b/crates/iwe/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `--filter` accepts the natural form `{type: tracker, $or: [...]}` directly — bare field keys may be mixed with `$and`/`$or`/`$nor`/`$key`/graph operators at the filter root and inside logical-operator branches, combining via implicit AND (previously rejected; required the explicit `{$and: [{type: tracker}, {$or: [...]}]}` rewrite). +- `--not-in KEY` deprecation warning now points to `--filter '$nor: [{ $includedBy: ... }]'` (was: `--filter '$not: { $includedBy: ... }'`). + +### Removed + +- Top-level `$not` in `--filter` expressions. `$not` is now field-level only (matching MongoDB): `--filter 'priority: { $not: { $gt: 5 } }'` still works; `--filter '$not: { status: archived }'` is now a parse-time error and should be rewritten as `--filter '$nor: [{ status: archived }]'`. The error message points to `$nor`. + ## [0.1.1](https://github.com/iwe-org/iwe/compare/iwe-v0.1.0...iwe-v0.1.1) - 2026-05-03 ### Added diff --git a/crates/iwe/help/export/after_help.txt b/crates/iwe/help/export/after_help.txt index 25b68ba..9cab66d 100644 --- a/crates/iwe/help/export/after_help.txt +++ b/crates/iwe/help/export/after_help.txt @@ -48,7 +48,7 @@ EXAMPLES: # Restrict to a subtree via filter iwe export --included-by projects/alpha - iwe export --included-by projects/alpha --filter '$not: { $includedBy: archive }' + iwe export --included-by projects/alpha --filter '$nor: [{ $includedBy: archive }]' USING DOT OUTPUT: diff --git a/crates/iwe/src/filter_args.rs b/crates/iwe/src/filter_args.rs index 4d701a6..cf4715f 100644 --- a/crates/iwe/src/filter_args.rs +++ b/crates/iwe/src/filter_args.rs @@ -213,10 +213,10 @@ impl FilterArgs { )); } for k in &self.not_in { - eprintln!("warning: --not-in is deprecated; use --filter '$not: {{ $includedBy: ... }}'"); - conjuncts.push(Filter::Not(Box::new(Filter::IncludedBy(Box::new( + eprintln!("warning: --not-in is deprecated; use --filter '$nor: [{{ $includedBy: ... }}]'"); + conjuncts.push(Filter::Nor(vec![Filter::IncludedBy(Box::new( InclusionAnchor::with_max(k, LEGACY_ALIAS_DEPTH), - ))))); + ))])); } if let Some(k) = &self.refs_to { eprintln!("warning: --refs-to is deprecated; use --references"); diff --git a/crates/iwe/tests/cli_filter_test.rs b/crates/iwe/tests/cli_filter_test.rs index b40480e..11fbddd 100644 --- a/crates/iwe/tests/cli_filter_test.rs +++ b/crates/iwe/tests/cli_filter_test.rs @@ -515,3 +515,40 @@ fn update_set_preserves_body_exactly() { ); } +#[test] +fn find_filter_top_level_bare_and_or_implicit_and() { + let dir = setup(); + let (stdout, _, ok) = run( + dir.path(), + "find", + &[ + "--filter", + "{status: draft, $or: [{priority: 3}, {priority: 5}]}", + "-f", + "keys", + ], + ); + assert!(ok); + assert_eq!(stdout, "a\n"); +} + +#[test] +fn find_filter_field_value_mix_rejected_with_clear_message() { + let dir = setup(); + let (_, stderr, code) = run_with_code( + dir.path(), + "find", + &[ + "--filter", + "{author: {$eq: alice, name: alice}}", + "-f", + "keys", + ], + ); + assert_eq!(code, 2); + assert_eq!( + stderr, + "error: invalid --filter expression: cannot mix operator keys ($...) and bare keys inside a field-value mapping at 'author' (use one form: either all operators on the field, or only nested-field references)\n" + ); +} + diff --git a/crates/iwec/CHANGELOG.md b/crates/iwec/CHANGELOG.md index 90646ab..c1e0313 100644 --- a/crates/iwec/CHANGELOG.md +++ b/crates/iwec/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Filter expressions in `iwe_find` / `iwe_update` / `iwe_delete` accept the natural form `{type: tracker, $or: [...]}` — bare field keys may be mixed with `$and`/`$or`/`$nor`/`$key`/graph operators at document-matching positions, combining via implicit AND (previously rejected). + +### Removed + +- Top-level `$not` in MCP query filters. `$not` is now field-level only (matching MongoDB); use `$nor: [filter]` for document-level negation. Top-level `$not` returns a parse-time error pointing to `$nor`. + ## [0.1.1](https://github.com/iwe-org/iwe/compare/iwec-v0.1.0...iwec-v0.1.1) - 2026-05-03 Workspace version bump — no user-visible changes in this crate. diff --git a/crates/iwec/src/lib.rs b/crates/iwec/src/lib.rs index 651cdda..4b1d04c 100644 --- a/crates/iwec/src/lib.rs +++ b/crates/iwec/src/lib.rs @@ -118,9 +118,9 @@ impl SelectorParams { )); } for kd in &self.not_in { - conjuncts.push(Filter::Not(Box::new(Filter::IncludedBy(Box::new( + conjuncts.push(Filter::Nor(vec![Filter::IncludedBy(Box::new( kd.anchor(self.max_depth), - ))))); + ))])); } Some(Filter::And(conjuncts)) } diff --git a/crates/iwes/CHANGELOG.md b/crates/iwes/CHANGELOG.md index c159304..b60c9ad 100644 --- a/crates/iwes/CHANGELOG.md +++ b/crates/iwes/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Filter expressions in routed query operations accept the natural form `{type: tracker, $or: [...]}` — bare field keys may be mixed with `$and`/`$or`/`$nor`/`$key`/graph operators at document-matching positions, combining via implicit AND (previously rejected). + +### Removed + +- Top-level `$not` in routed query filters. `$not` is now field-level only (matching MongoDB); use `$nor: [filter]` for document-level negation. Top-level `$not` returns a parse-time error pointing to `$nor`. + ## [0.1.1](https://github.com/iwe-org/iwe/compare/iwes-v0.1.0...iwes-v0.1.1) - 2026-05-03 Workspace version bump — no user-visible changes in this crate. diff --git a/crates/liwe/CHANGELOG.md b/crates/liwe/CHANGELOG.md index 5944546..7980dc2 100644 --- a/crates/liwe/CHANGELOG.md +++ b/crates/liwe/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Filter parser allows mixing bare field keys with document-level operators (`$and`, `$or`, `$nor`, `$key`, `$includes`, `$includedBy`, `$references`, `$referencedBy`) at document-matching positions (filter root, branches of `$and`/`$or`/`$nor`, graph-anchor `match` clauses); they combine via implicit AND (was rejected with `cannot mix operator keys ($...) and bare keys`). Mixing inside a field-value mapping (e.g. `author: { $eq: alice, name: alice }`) and inside a field-level `$not` body remains rejected. +- `MixedDollarAndBare` error message names the offending position ("inside a field-value mapping at ''") and suggests the fix. + +### Removed + +- Top-level `$not` operator. `$not` is now field-level only (matching MongoDB), e.g. `priority: { $not: { $gt: 5 } }`. For document-level negation use `$nor: [filter]`. The `Filter::Not` AST variant is removed; internal callers that previously constructed `Filter::Not(Box::new(inner))` now use `Filter::Nor(vec![inner])` (semantically identical). The `not()` constructor in `query::prelude` is removed; use `nor(vec![filter])` instead. + ## [0.1.1](https://github.com/iwe-org/iwe/compare/liwe-v0.1.0...liwe-v0.1.1) - 2026-05-03 ### Added diff --git a/crates/liwe/src/markdown/writer.rs b/crates/liwe/src/markdown/writer.rs index 441ab17..55aa92b 100644 --- a/crates/liwe/src/markdown/writer.rs +++ b/crates/liwe/src/markdown/writer.rs @@ -227,13 +227,13 @@ impl MarkdownWriter { ) -> String { let header_strs: Vec = header .iter() - .map(|c| inlines_to_markdown(c, &self.options)) + .map(|c| inlines_to_markdown(c, &self.options).replace('|', "\\|")) .collect(); let row_strs: Vec> = rows .iter() .map(|row| { row.iter() - .map(|c| inlines_to_markdown(c, &self.options)) + .map(|c| inlines_to_markdown(c, &self.options).replace('|', "\\|")) .collect() }) .collect(); diff --git a/crates/liwe/src/query/builder.rs b/crates/liwe/src/query/builder.rs index 6a7afcc..7aeaacf 100644 --- a/crates/liwe/src/query/builder.rs +++ b/crates/liwe/src/query/builder.rs @@ -26,6 +26,9 @@ pub enum ParseError { MixedDollarAndBare { path: Vec, }, + TopLevelNotNotSupported { + path: Vec, + }, UnknownOperator { op: String, path: Vec, @@ -149,9 +152,19 @@ impl std::fmt::Display for ParseError { write!(f, "'{}' requires the '{}' field", fmt_kind(kind), field) } Self::EmptyFilter => write!(f, "filter expression is empty"), - Self::MixedDollarAndBare { path } => { - write!(f, "cannot mix operator keys ($...) and bare keys at '{}'", fmt_path(path)) - } + Self::MixedDollarAndBare { path } => write!( + f, + "cannot mix operator keys ($...) and bare keys inside a field-value mapping at '{}' \ + (use one form: either all operators on the field, or only nested-field references)", + fmt_path(path) + ), + Self::TopLevelNotNotSupported { path } => write!( + f, + "'$not' is not a document-level operator at '{}' \ + (use '$nor: [filter]' for document-level negation; \ + '$not' is only valid as a field-level operator: 'field: {{ $not: {{ $op: ... }} }}')", + fmt_path(path) + ), Self::UnknownOperator { op, path } => { write!(f, "unknown operator '{}' at '{}'", op, fmt_path(path)) } @@ -398,49 +411,35 @@ fn build_filter(raw: RawFilter) -> Result { fn build_filter_at(map: Mapping, path: &[String]) -> Result { if map.is_empty() { - - return Ok(Filter::And(Vec::new())); } let (dollar_keys, bare_keys) = classify_keys(&map)?; - if !dollar_keys.is_empty() && !bare_keys.is_empty() { - return Err(ParseError::MixedDollarAndBare { - path: path.to_vec(), - }); - } - if !dollar_keys.is_empty() { + let mut clauses: Vec = Vec::with_capacity(dollar_keys.len() + bare_keys.len()); - let mut clauses: Vec = Vec::new(); - for op in dollar_keys { - let value = &map[Value::String(op.clone())]; - clauses.push(build_filter_op(&op, value, path)?); - } - if clauses.len() == 1 { - Ok(clauses.into_iter().next().unwrap()) + for op in dollar_keys { + let value = &map[Value::String(op.clone())]; + clauses.push(build_filter_op(&op, value, path)?); + } + + for key_str in bare_keys { + let segments: Vec = if key_str.contains('.') { + key_str.split('.').map(|s| s.to_string()).collect() } else { - Ok(Filter::And(clauses)) - } + vec![key_str.clone()] + }; + check_path_segments(&segments)?; + let mut child_path = path.to_vec(); + child_path.extend(segments.iter().cloned()); + let value = map[Value::String(key_str.clone())].clone(); + clauses.push(build_field_clause(&segments, value, &child_path)?); + } + + if clauses.len() == 1 { + Ok(clauses.into_iter().next().unwrap()) } else { - let mut clauses: Vec = Vec::new(); - for key_str in bare_keys { - let segments: Vec = if key_str.contains('.') { - key_str.split('.').map(|s| s.to_string()).collect() - } else { - vec![key_str.clone()] - }; - check_path_segments(&segments)?; - let mut child_path = path.to_vec(); - child_path.extend(segments.iter().cloned()); - let value = map[Value::String(key_str.clone())].clone(); - clauses.push(build_field_clause(&segments, value, &child_path)?); - } - if clauses.len() == 1 { - Ok(clauses.into_iter().next().unwrap()) - } else { - Ok(Filter::And(clauses)) - } + Ok(Filter::And(clauses)) } } @@ -466,7 +465,9 @@ fn build_filter_op(op: &str, value: &Value, path: &[String]) -> Result Ok(Filter::And(parse_filter_list(value, "$and", path)?)), "$or" => Ok(Filter::Or(parse_filter_list(value, "$or", path)?)), "$nor" => Ok(Filter::Nor(parse_filter_list(value, "$nor", path)?)), - "$not" => Ok(Filter::Not(Box::new(parse_not(value, path)?))), + "$not" => Err(ParseError::TopLevelNotNotSupported { + path: path.to_vec(), + }), "$key" => Ok(Filter::Key(parse_key_op(value, "$key")?)), "$includes" => Ok(Filter::Includes(Box::new(parse_inclusion_arg(value, "$includes")?))), "$includedBy" => Ok(Filter::IncludedBy(Box::new(parse_inclusion_arg(value, "$includedBy")?))), @@ -501,14 +502,6 @@ fn parse_filter_list( .collect() } -fn parse_not(value: &Value, path: &[String]) -> Result { - let m = value - .as_mapping() - .ok_or(ParseError::OperatorExpectedMapping { op: "$not" })? - .clone(); - build_filter_at(m, path) -} - fn static_op_name(op: &str) -> &'static str { match op { "$and" => "$and", @@ -1280,26 +1273,39 @@ mod tests { } #[test] - fn filter_double_not_top_level_parses() { + fn filter_top_level_bare_and_dollar_implicit_and() { let op = parse( - "filter:\n $not:\n $not:\n status: draft\n", + "filter:\n type: tracker\n $or:\n - status: open\n - status: pending\n", OperationKind::Find, ) .unwrap(); - if let Operation::Find(find) = op { - let f = find.filter.unwrap(); - match f { - Filter::Not(inner) => match *inner { - Filter::Not(_) => {} - other => panic!("expected nested Not, got {:?}", other), - }, - other => panic!("expected Not, got {:?}", other), + let Operation::Find(find) = op else { panic!("expected Find") }; + let parts = match find.filter.unwrap() { + Filter::And(p) => p, + other => panic!("expected And, got {:?}", other), + }; + assert_eq!(parts.len(), 2); + match &parts[0] { + Filter::Or(branches) => assert_eq!(branches.len(), 2), + other => panic!("expected Or first (dollar group), got {:?}", other), + } + match &parts[1] { + Filter::Field { path, op: _ } => { + assert_eq!(path.segments(), &["type".to_string()]); } - } else { - panic!() + other => panic!("expected Field second (bare group), got {:?}", other), } } + #[test] + fn filter_top_level_not_rejected() { + let err = parse_err( + "filter:\n $not:\n status: draft\n", + OperationKind::Find, + ); + assert!(matches!(err, ParseError::TopLevelNotNotSupported { .. })); + } + #[test] fn filter_empty_and_rejected() { let err = parse_err("filter:\n $and: []\n", OperationKind::Find); diff --git a/crates/liwe/src/query/document.rs b/crates/liwe/src/query/document.rs index c580f8d..4d495ad 100644 --- a/crates/liwe/src/query/document.rs +++ b/crates/liwe/src/query/document.rs @@ -141,7 +141,6 @@ pub enum Filter { And(Vec), Or(Vec), Nor(Vec), - Not(Box), Field { path: FieldPath, op: FieldOp }, Key(KeyOp), Includes(Box), diff --git a/crates/liwe/src/query/eval.rs b/crates/liwe/src/query/eval.rs index 3fcf7bb..08dd6e4 100644 --- a/crates/liwe/src/query/eval.rs +++ b/crates/liwe/src/query/eval.rs @@ -31,7 +31,6 @@ fn eval(filter: &Filter, graph: &Graph, scope: Option<&HashSet>) -> HashSet Filter::And(children) => eval_and(children, graph, scope), Filter::Or(children) => eval_or(children, graph, scope), Filter::Nor(children) => eval_nor(children, graph, scope), - Filter::Not(inner) => eval_not(inner, graph, scope), Filter::Field { path, op } => eval_field(path, op, graph, scope), Filter::Key(op) => eval_key(op, graph, scope), Filter::Includes(anchor) => eval_inclusion(anchor, graph, scope, true), @@ -89,12 +88,6 @@ fn eval_or(children: &[Filter], graph: &Graph, scope: Option<&HashSet>) -> }) } -fn eval_not(inner: &Filter, graph: &Graph, scope: Option<&HashSet>) -> HashSet { - let universe = scope.cloned().unwrap_or_else(|| all_keys(graph)); - let inner_set = eval(inner, graph, Some(&universe)); - universe.into_iter().filter(|k| !inner_set.contains(k)).collect() -} - fn eval_nor(children: &[Filter], graph: &Graph, scope: Option<&HashSet>) -> HashSet { let universe = scope.cloned().unwrap_or_else(|| all_keys(graph)); let union = eval_or(children, graph, Some(&universe)); diff --git a/crates/liwe/src/query/filter.rs b/crates/liwe/src/query/filter.rs index 439e8ae..2dfa711 100644 --- a/crates/liwe/src/query/filter.rs +++ b/crates/liwe/src/query/filter.rs @@ -317,7 +317,7 @@ mod tests { } fn and(filters: Vec) -> Filter { Filter::And(filters) } fn or(filters: Vec) -> Filter { Filter::Or(filters) } - fn not(f: Filter) -> Filter { Filter::Not(Box::new(f)) } + fn nor(filters: Vec) -> Filter { Filter::Nor(filters) } fn doc(pairs: Vec<(&str, Value)>) -> Mapping { @@ -335,7 +335,7 @@ mod tests { match filter { Filter::And(children) => children.iter().all(|c| matches_doc(c, doc)), Filter::Or(children) => children.iter().any(|c| matches_doc(c, doc)), - Filter::Not(child) => !matches_doc(child, doc), + Filter::Nor(children) => !children.iter().any(|c| matches_doc(c, doc)), Filter::Field { path, op } => match resolve_path(doc, path) { Resolution::Present(v) => match_field_op(op, Some(v)), Resolution::Missing => match_field_op(op, None), @@ -597,8 +597,8 @@ mod tests { } #[test] - fn not_matches_missing_field() { - let f = not(eq("reviewed", true)); + fn nor_matches_missing_field() { + let f = nor(vec![eq("reviewed", true)]); check(&f, &Mapping::new(), true); check(&f, &doc(vec![("reviewed", false.into())]), true); check(&f, &doc(vec![("reviewed", true.into())]), false); diff --git a/crates/liwe/src/query/prelude.rs b/crates/liwe/src/query/prelude.rs index 7a978e5..d44f279 100644 --- a/crates/liwe/src/query/prelude.rs +++ b/crates/liwe/src/query/prelude.rs @@ -63,10 +63,6 @@ pub fn nor(filters: Vec) -> Filter { Filter::Nor(filters) } -pub fn not(filter: Filter) -> Filter { - Filter::Not(Box::new(filter)) -} - pub fn eq(path: &str, v: impl Into) -> Filter { Filter::eq(path, v) } diff --git a/crates/liwe/tests/normalization_misc.rs b/crates/liwe/tests/normalization_misc.rs index 76ed7ee..e8caef4 100644 --- a/crates/liwe/tests/normalization_misc.rs +++ b/crates/liwe/tests/normalization_misc.rs @@ -318,6 +318,24 @@ fn table_with_alignment() { ); } +#[test] +fn table_with_pipe_in_code() { + setup(); + compare( + indoc! {" + | header | + | -------- | + | `a \\| b` | + + "}, + indoc! {" + | header | + | --- | + | `a \\| b` | + "}, + ); +} + fn compare(expected: &str, denormalized: &str) { setup(); diff --git a/crates/liwe/tests/query_deserialize.rs b/crates/liwe/tests/query_deserialize.rs index 63c1890..380db72 100644 --- a/crates/liwe/tests/query_deserialize.rs +++ b/crates/liwe/tests/query_deserialize.rs @@ -1,7 +1,7 @@ use indoc::indoc; use liwe::query::prelude::{ all, and, count, delete, eq, exists, filter, find, gt, gte, in_, included_by, includes, - key_eq, key_in, lt, lte, ne, nin, nor, not, or, referenced_by, references, size, type_of, + key_eq, key_in, lt, lte, ne, nin, nor, or, referenced_by, references, size, type_of, update, update_op, }; use liwe::query::{ @@ -314,15 +314,15 @@ fn filter_or() { } #[test] -fn filter_top_level_not() { - assert_parse( +fn filter_top_level_not_rejected() { + assert_parse_error( indoc! {" filter: $not: x: 1 "}, OperationKind::Find, - find(filter(not(eq("x", 1i64)))), + "TopLevelNotNotSupported", ); } @@ -355,20 +355,6 @@ fn filter_nor_empty_rejected() { ); } -#[test] -fn filter_nested_not_top_level_parses() { - assert_parse( - indoc! {" - filter: - $not: - $not: - x: 1 - "}, - OperationKind::Find, - find(filter(not(not(eq("x", 1i64))))), - ); -} - #[test] fn filter_per_field_nested_not_parses() { assert_parse( diff --git a/crates/liwe/tests/query_filter_expression.rs b/crates/liwe/tests/query_filter_expression.rs index 3512193..4e50b6c 100644 --- a/crates/liwe/tests/query_filter_expression.rs +++ b/crates/liwe/tests/query_filter_expression.rs @@ -1,4 +1,4 @@ -use liwe::query::{parse_filter_expression, Filter, KeyOp}; +use liwe::query::{parse_filter_expression, Filter, KeyOp, ParseError}; #[test] fn parses_block_style_field_eq() { @@ -60,20 +60,227 @@ fn whitespace_only_input_yields_empty_and() { } #[test] -fn rejects_mixed_dollar_and_bare_at_same_level() { - let err = parse_filter_expression("{$eq: foo, name: bar}"); - assert!(err.is_err(), "expected MixedDollarAndBare error"); +fn parses_top_level_bare_and_or() { + let f = parse_filter_expression( + "{type: tracker, $or: [{status: open}, {status: pending}]}", + ) + .unwrap(); + let parts = match f { + Filter::And(p) => p, + other => panic!("expected And, got {:?}", other), + }; + assert_eq!(parts.len(), 2); + match &parts[0] { + Filter::Or(branches) => assert_eq!(branches.len(), 2), + other => panic!("expected Or first (dollar group), got {:?}", other), + } + match &parts[1] { + Filter::Field { path, op: _ } => { + assert_eq!(path.segments(), &["type".to_string()]); + } + other => panic!("expected Field second (bare group), got {:?}", other), + } } #[test] -fn parses_nested_not() { - let f = parse_filter_expression("$not: { $not: { status: draft } }").unwrap(); - match f { - Filter::Not(inner) => match *inner { - Filter::Not(_) => {} - other => panic!("expected nested Not, got {:?}", other), - }, - other => panic!("expected Not, got {:?}", other), +fn parses_top_level_bare_and_and() { + let f = parse_filter_expression("{a: 1, $and: [{b: 2}]}").unwrap(); + let parts = match f { + Filter::And(p) => p, + other => panic!("expected And, got {:?}", other), + }; + assert_eq!(parts.len(), 2); + match &parts[0] { + Filter::And(inner) => assert_eq!(inner.len(), 1), + other => panic!("expected inner And first, got {:?}", other), + } + match &parts[1] { + Filter::Field { path, op: _ } => assert_eq!(path.segments(), &["a".to_string()]), + other => panic!("expected Field second, got {:?}", other), + } +} + +#[test] +fn parses_top_level_bare_and_nor() { + let f = parse_filter_expression("{a: 1, $nor: [{b: 2}]}").unwrap(); + let parts = match f { + Filter::And(p) => p, + other => panic!("expected And, got {:?}", other), + }; + assert_eq!(parts.len(), 2); + match &parts[0] { + Filter::Nor(inner) => assert_eq!(inner.len(), 1), + other => panic!("expected Nor first, got {:?}", other), + } + match &parts[1] { + Filter::Field { path, op: _ } => assert_eq!(path.segments(), &["a".to_string()]), + other => panic!("expected Field second, got {:?}", other), + } +} + +#[test] +fn rejects_top_level_not() { + let err = parse_filter_expression("{$not: {b: 2}}").unwrap_err(); + match err { + ParseError::TopLevelNotNotSupported { ref path } => { + assert!(path.is_empty()); + } + other => panic!("expected TopLevelNotNotSupported, got {:?}", other), + } +} + +#[test] +fn parses_top_level_bare_and_key() { + let f = parse_filter_expression("{type: t, $key: notes/foo}").unwrap(); + let parts = match f { + Filter::And(p) => p, + other => panic!("expected And, got {:?}", other), + }; + assert_eq!(parts.len(), 2); + match &parts[0] { + Filter::Key(KeyOp::Eq(k)) => assert_eq!(k.to_string(), "notes/foo"), + other => panic!("expected Key(Eq) first, got {:?}", other), + } + match &parts[1] { + Filter::Field { path, op: _ } => assert_eq!(path.segments(), &["type".to_string()]), + other => panic!("expected Field second, got {:?}", other), + } +} + +#[test] +fn parses_top_level_multiple_bare_and_multiple_dollar() { + let f = parse_filter_expression( + "{a: 1, b: 2, $or: [{c: 3}], $and: [{d: 4}]}", + ) + .unwrap(); + let parts = match f { + Filter::And(p) => p, + other => panic!("expected And, got {:?}", other), + }; + assert_eq!(parts.len(), 4); + assert!(matches!(&parts[0], Filter::And(_) | Filter::Or(_))); + assert!(matches!(&parts[1], Filter::And(_) | Filter::Or(_))); + assert!(matches!(&parts[2], Filter::Field { .. })); + assert!(matches!(&parts[3], Filter::Field { .. })); +} + +#[test] +fn parses_mix_inside_or_branch() { + let f = parse_filter_expression( + "$or: [{a: 1, $nor: [{b: 2}]}, {c: 3}]", + ) + .unwrap(); + let branches = match f { + Filter::Or(b) => b, + other => panic!("expected Or, got {:?}", other), + }; + assert_eq!(branches.len(), 2); + let first = match &branches[0] { + Filter::And(p) => p, + other => panic!("expected And inside Or branch, got {:?}", other), + }; + assert_eq!(first.len(), 2); + match &first[0] { + Filter::Nor(inner) => assert_eq!(inner.len(), 1), + other => panic!("expected Nor first, got {:?}", other), + } + match &first[1] { + Filter::Field { path, op: _ } => assert_eq!(path.segments(), &["a".to_string()]), + other => panic!("expected Field second, got {:?}", other), + } +} + +#[test] +fn parses_mix_inside_and_branch() { + let f = parse_filter_expression("$and: [{a: 1, $or: [{b: 2}]}]").unwrap(); + let branches = match f { + Filter::And(b) => b, + other => panic!("expected And, got {:?}", other), + }; + assert_eq!(branches.len(), 1); + let inner = match &branches[0] { + Filter::And(p) => p, + other => panic!("expected And inside And branch, got {:?}", other), + }; + assert_eq!(inner.len(), 2); + match &inner[0] { + Filter::Or(_) => {} + other => panic!("expected Or first, got {:?}", other), + } + match &inner[1] { + Filter::Field { path, op: _ } => assert_eq!(path.segments(), &["a".to_string()]), + other => panic!("expected Field second, got {:?}", other), + } +} + +#[test] +fn parses_mix_inside_nor_branch() { + let f = parse_filter_expression("$nor: [{a: 1, $or: [{b: 2}]}]").unwrap(); + let branches = match f { + Filter::Nor(b) => b, + other => panic!("expected Nor, got {:?}", other), + }; + assert_eq!(branches.len(), 1); + let inner = match &branches[0] { + Filter::And(p) => p, + other => panic!("expected And inside Nor branch, got {:?}", other), + }; + assert_eq!(inner.len(), 2); + match &inner[0] { + Filter::Or(_) => {} + other => panic!("expected Or first, got {:?}", other), + } + match &inner[1] { + Filter::Field { path, op: _ } => assert_eq!(path.segments(), &["a".to_string()]), + other => panic!("expected Field second, got {:?}", other), + } +} + +#[test] +fn parses_mix_inside_graph_anchor_match() { + let f = parse_filter_expression( + "$includedBy: { match: {a: 1, $key: notes/foo}, maxDepth: 3 }", + ) + .unwrap(); + let anchor = match f { + Filter::IncludedBy(a) => a, + other => panic!("expected IncludedBy, got {:?}", other), + }; + assert_eq!(anchor.max_depth, 3); + let parts = match &anchor.match_filter { + Filter::And(p) => p, + other => panic!("expected And in match, got {:?}", other), + }; + assert_eq!(parts.len(), 2); + match &parts[0] { + Filter::Key(KeyOp::Eq(k)) => assert_eq!(k.to_string(), "notes/foo"), + other => panic!("expected Key(Eq) first, got {:?}", other), + } + match &parts[1] { + Filter::Field { path, op: _ } => assert_eq!(path.segments(), &["a".to_string()]), + other => panic!("expected Field second, got {:?}", other), + } +} + +#[test] +fn rejects_mix_inside_field_value_mapping() { + let err = parse_filter_expression("{author: {$eq: alice, name: alice}}").unwrap_err(); + match err { + ParseError::MixedDollarAndBare { ref path } => { + assert_eq!(path, &vec!["author".to_string()]); + } + other => panic!("expected MixedDollarAndBare, got {:?}", other), + } +} + +#[test] +fn rejects_mix_inside_field_level_not() { + let err = parse_filter_expression("{score: {$not: {$gt: 5, extra: 1}}}").unwrap_err(); + match err { + ParseError::MixedDollarAndBare { ref path } => { + assert_eq!(path, &vec!["score".to_string()]); + } + other => panic!("expected MixedDollarAndBare, got {:?}", other), } } diff --git a/crates/liwe/tests/query_graph.rs b/crates/liwe/tests/query_graph.rs index 29a4a4f..c5f6ae3 100644 --- a/crates/liwe/tests/query_graph.rs +++ b/crates/liwe/tests/query_graph.rs @@ -2,7 +2,7 @@ use indoc::indoc; use liwe::graph::Graph; use liwe::model::config::MarkdownOptions; use liwe::query::prelude::{ - and, eq, filter, find, included_by, includes, key_eq, key_in, key_ne, key_nin, nor, not, or, + and, eq, filter, find, included_by, includes, key_eq, key_in, key_ne, key_nin, nor, or, referenced_by, references, }; use liwe::query::{execute, FindOp, InclusionAnchor, Outcome, ReferenceAnchor}; @@ -396,7 +396,7 @@ fn or_of_two_graph_ops() { } #[test] -fn not_wraps_walk() { +fn nor_wraps_walk() { assert_keys( indoc! {" [b](2) @@ -405,7 +405,7 @@ fn not_wraps_walk() { _ # C "}, - filter(not(included_by(InclusionAnchor::with_max("1", 5)))), + filter(nor(vec![included_by(InclusionAnchor::with_max("1", 5))])), &["1", "3"], ); } @@ -425,24 +425,6 @@ fn nor_excludes_union_of_children() { ); } -#[test] -fn nor_equivalent_to_not_or() { - let docs = indoc! {" - # A - _ - # B - _ - # C - "}; - let nor_op = filter(nor(vec![key_eq("1"), key_eq("2")])); - let not_or = filter(not(or(vec![key_eq("1"), key_eq("2")]))); - let mut a = run_find_keys(docs, nor_op); - let mut b = run_find_keys(docs, not_or); - a.sort(); - b.sort(); - assert_eq!(a, b); -} - #[test] fn combined_three_predicates_hub_under_anchor() { assert_keys( diff --git a/docs/architecture.md b/docs/architecture.md index 9a942cd..f0da69b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,3 +17,5 @@ IWE's data model is built around a **graph-based representation** of markdown do [Indexing and Reference Systems](indexing.md) [Advanced Features](graph-operations.md) + +[Benchmark](benchmark.md) diff --git a/docs/cli-export.md b/docs/cli-export.md index 08586e6..5f48ee0 100644 --- a/docs/cli-export.md +++ b/docs/cli-export.md @@ -136,6 +136,6 @@ The following flags pre-date the query language and remain accepted for backward | ------------------ | --------------------------------------------------------------------------- | | `--in KEY[:N]` | `--included-by KEY[:N]` | | `--in-any K1 K2` | `--filter '$or: [{ $includedBy: K1 }, { $includedBy: K2 }]'` | -| `--not-in KEY` | `--filter '$not: { $includedBy: KEY }'` | +| `--not-in KEY` | `--filter '$nor: [{ $includedBy: KEY }]'` | | `--refs-to KEY` | `--references KEY` (legacy semantics: ORs `$includes` and `$references`) | | `--refs-from KEY` | `--referenced-by KEY` (legacy semantics: ORs `$includedBy` and `$referencedBy`) | diff --git a/docs/cli-find.md b/docs/cli-find.md index 02229a5..60bd689 100644 --- a/docs/cli-find.md +++ b/docs/cli-find.md @@ -157,7 +157,7 @@ iwe find authentication iwe find auth --filter 'status: draft' # Roots — documents with no incoming inclusion edges -iwe find --filter '$not: { $includedBy: { match: {} } }' +iwe find --filter '$nor: [{ $includedBy: { match: {} } }]' # Limit iwe find --limit 10 @@ -177,14 +177,14 @@ The following flags pre-date the query language and remain accepted for backward | ------------------ | --------------------------------------------------------------------------- | | `--in KEY[:N]` | `--included-by KEY[:N]` | | `--in-any K1 K2` | `--filter '$or: [{ $includedBy: K1 }, { $includedBy: K2 }]'` | -| `--not-in KEY` | `--filter '$not: { $includedBy: KEY }'` | +| `--not-in KEY` | `--filter '$nor: [{ $includedBy: KEY }]'` | | `--refs-to KEY` | `--references KEY` (legacy semantics: ORs `$includes` and `$references`) | | `--refs-from KEY` | `--referenced-by KEY` (legacy semantics: ORs `$includedBy` and `$referencedBy`) | -| `--roots` | `--filter '$not: { $includedBy: { match: {} } }'` | +| `--roots` | `--filter '$nor: [{ $includedBy: { match: {} } }]'` | ## Technical notes -- All filter flags AND together at the top level. To compose with OR or NOT, wrap inside `--filter`. +- All filter flags AND together at the top level. To compose with OR or NOR, wrap inside `--filter`. - The colon-suffix on an anchor flag (`KEY:N`) overrides `--max-depth` / `--max-distance` for that anchor only. `0` is the unbounded sentinel. - Combining `-k KEY` with a `--filter` whose top level also contains `$key` is a parse-time error. Use `-k a -k b` for multi-key match (lowers to `$key: { $in: [a, b] }`), or write the OR inside `--filter`. - Both [Inclusion Links](inclusion-links.md) and inline references count toward `incoming_refs`. diff --git a/docs/cli-retrieve.md b/docs/cli-retrieve.md index c85d2f2..ef88fad 100644 --- a/docs/cli-retrieve.md +++ b/docs/cli-retrieve.md @@ -354,7 +354,7 @@ The following flags pre-date the query language and remain accepted for backward | ------------------ | --------------------------------------------------------------------------- | | `--in KEY[:N]` | `--included-by KEY[:N]` | | `--in-any K1 K2` | `--filter '$or: [{ $includedBy: K1 }, { $includedBy: K2 }]'` | -| `--not-in KEY` | `--filter '$not: { $includedBy: KEY }'` | +| `--not-in KEY` | `--filter '$nor: [{ $includedBy: KEY }]'` | | `--refs-to KEY` | `--references KEY` (legacy semantics: ORs `$includes` and `$references`) | | `--refs-from KEY` | `--referenced-by KEY` (legacy semantics: ORs `$includedBy` and `$referencedBy`) | diff --git a/docs/cli-tree.md b/docs/cli-tree.md index 6672dde..a28af0b 100644 --- a/docs/cli-tree.md +++ b/docs/cli-tree.md @@ -152,6 +152,6 @@ The following flags pre-date the query language and remain accepted for backward | ------------------ | --------------------------------------------------------------------------- | | `--in KEY[:N]` | `--included-by KEY[:N]` | | `--in-any K1 K2` | `--filter '$or: [{ $includedBy: K1 }, { $includedBy: K2 }]'` | -| `--not-in KEY` | `--filter '$not: { $includedBy: KEY }'` | +| `--not-in KEY` | `--filter '$nor: [{ $includedBy: KEY }]'` | | `--refs-to KEY` | `--references KEY` (legacy semantics: ORs `$includes` and `$references`) | | `--refs-from KEY` | `--referenced-by KEY` (legacy semantics: ORs `$includedBy` and `$referencedBy`) | diff --git a/docs/query-language.md b/docs/query-language.md index 1cc59f0..4528ba9 100644 --- a/docs/query-language.md +++ b/docs/query-language.md @@ -56,9 +56,6 @@ $or: - status: draft - status: review -$not: - status: archived - $nor: - status: archived - status: deleted @@ -193,11 +190,11 @@ On the CLI, structural anchor flags lower to graph operators. A `KEY[:DEPTH]` su | `--set FIELD=VALUE` | `$set: { FIELD: VALUE }` (update only; repeatable) | | `--unset FIELD` | `$unset: { FIELD: "" }` (update only; repeatable) | -All filter flags AND together. For OR or NOT, write the composition inside `--filter`: +All filter flags AND together. For OR or NOR, write the composition inside `--filter`: ```bash iwe find --filter '$or: [{ status: draft }, { status: review }]' -iwe find --filter '$not: { status: archived }' +iwe find --filter '$nor: [{ status: archived }]' ``` Combining `-k KEY` with a `--filter` whose top level also contains `$key` is a parse-time error — pick one source, or use `-k a -k b` for multi-key match. diff --git a/docs/spec.md b/docs/spec.md index 3021791..2a9c118 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -150,7 +150,7 @@ limit: 500 A filter document is a predicate evaluated against each document in the corpus. A document matches when every top-level key matches. -Filter top-level keys are either user frontmatter field names (e.g. `status`, `priority`, `tags`) or `$`-prefixed operator names. The operator family includes the logical operators (`$and`, `$or`, `$not`, `$nor`; §4.6) and the **graph operators** (`$key`, `$includes`, `$includedBy`, `$references`, `$referencedBy`) defined in §5. Both kinds compose freely with frontmatter predicates under the same algebra. +Filter top-level keys are either user frontmatter field names (e.g. `status`, `priority`, `tags`) or `$`-prefixed operator names. The operator family includes the document-level logical operators (`$and`, `$or`, `$nor`; §4.6) and the **graph operators** (`$key`, `$includes`, `$includedBy`, `$references`, `$referencedBy`) defined in §5. Both kinds compose freely with frontmatter predicates under the same algebra. `$not` exists only as a **field-level** operator (§4.6); document-level negation is expressed via `$nor`. ### 4.1 Implicit equality (bare values) @@ -188,21 +188,45 @@ Multiple operators in one expression are ANDed: priority: { $gte: 3, $lte: 7 } # 3 ≤ priority ≤ 7 ``` -A mapping with **mixed** `$`-prefixed and bare keys at the same level is an error — it's ambiguous whether the bare keys are nested fields or part of the operator expression. Use one form per level: +A mapping with `$`-prefixed and bare keys at the same level behaves differently depending on **where** the mapping appears: -```yaml -# OK — operator expression -author: { $eq: alice } +- **At a document-matching position** — the filter root, every element of `$and` / `$or` / `$nor`, or the `match:` clause of a graph anchor — bare keys and operator keys may freely coexist. They are combined with **implicit AND** (consistent with §4.3 and §4.6). -# OK — nested field -author: - name: alice + ```yaml + type: tracker + $or: + - status: open + - status: pending + ``` -# ERROR — mixed -author: - $eq: alice - name: alice -``` + is equivalent to + + ```yaml + $and: + - type: tracker + - $or: + - status: open + - status: pending + ``` + +- **At a field-value position** — inside `{ field: { ... } }` — the mapping's keys must be either *all* `$`-prefixed operators or *all* bare nested-field references. Mixing is an error because a bare key inside a field-value mapping is ambiguous: it could be a nested-field path or an argument to a sibling operator. + + ```yaml + # OK — operator expression + author: { $eq: alice } + + # OK — nested fields + author: + name: alice + role: admin + + # ERROR — mixed (is `name` a nested field of `author`, or an argument to `$eq`?) + author: + $eq: alice + name: alice + ``` + + The same rule applies inside the body of a field-level `$not`: `{ field: { $not: { $gt: 5, x: 1 } } }` is rejected for the same reason. ### 4.3 Multiple keys are ANDed @@ -331,7 +355,7 @@ When in doubt, quote the value to force the string type, or leave it bare to acc ### 4.6 Logical operators -Four operators compose filters: `$and`, `$or`, `$not`, `$nor`. +Three document-level operators compose filters: `$and`, `$or`, `$nor`. They mirror MongoDB's logical operators and may appear at the filter root or as siblings of bare field keys (§4.2). A fourth operator, `$not`, exists only at the **field level** for negating a single field-value operator expression. #### `$and: [filter1, filter2, ...]` @@ -346,7 +370,7 @@ $and: - Every list element is a filter document. - A document matches if every sub-filter matches. - **Empty list** `$and: []` is a parse-time error. -- `$and` is **implicit at the top level** — multiple top-level keys in a filter are already ANDed (§4.3). Use explicit `$and` when you need to wrap a sub-expression for use inside `$or` / `$not`, or when you need to repeat a field name across multiple sub-filters (a YAML mapping cannot have duplicate keys). +- `$and` is **implicit at the top level** — multiple top-level keys in a filter are already ANDed (§4.3). Use explicit `$and` when you need to wrap a sub-expression for use inside `$or` / `$nor`, or when you need to repeat a field name across multiple sub-filters (a YAML mapping cannot have duplicate keys). ```yaml # Two ranges on `priority` — needs $and to repeat the key @@ -370,32 +394,9 @@ $or: - **Empty list** `$or: []` is a parse-time error. - Sub-filters are independent — each is evaluated against the whole document. -#### `$not: filter` - -The contained filter must not match. - -Top-level form: - -```yaml -$not: - status: archived -``` - -Per-field form (wraps a sub-expression for one field): - -```yaml -priority: { $not: { $gt: 5 } } -``` - -- Takes a single filter document (not a list). -- Negates the result. -- **Missing-field interaction:** `$not: { reviewed: true }` matches docs without a `reviewed` field, because the inner predicate doesn't match (missing field), and `$not` flips that to true. To require presence and inequality, combine: `reviewed: { $exists: true, $ne: true }`. -- `$not` may wrap any filter, including another `$not`. Double negation is redundant but legal — `$not: { $not: X }` parses and is equivalent to `X`. -- For "none of these match" over multiple sibling filters, use `$nor` (below) rather than `$not: { $or: [...] }`. Both forms are semantically equivalent; `$nor` is the idiomatic spelling. - #### `$nor: [filter1, filter2, ...]` -None of the listed filters may match. Equivalent to `$not: { $or: [filter1, filter2, ...] }` by De Morgan's law, and provided as a direct top-level operator because it's the conventional spelling for negative composition. +None of the listed filters may match. Use `$nor` for document-level negation: `$nor: [filter]` reads "documents where `filter` does not match". ```yaml $nor: @@ -408,7 +409,20 @@ $nor: - A document matches if **every** sub-filter fails to match. - **Empty list** `$nor: []` is a parse-time error. - Sub-filters are independent — each is evaluated against the whole document. -- **Missing-field interaction** follows the same rule as `$not`: a sub-filter that fails because the field is missing counts as a non-match, contributing to a `$nor` match. Use `$exists: true` inside the sub-filter when presence matters. +- **Missing-field interaction:** a sub-filter that fails because the field is missing counts as a non-match, contributing to a `$nor` match. To require presence and inequality, combine `$exists: true` with the negative predicate: `$nor: [{ reviewed: true }]` matches both "reviewed is false" and "reviewed is missing"; use `reviewed: { $exists: true, $ne: true }` to require the field be present. + +#### `$not: { $op: ... }` — field-level only + +`$not` is **field-level only** and wraps a single operator expression on the field: + +```yaml +priority: { $not: { $gt: 5 } } # priority is not > 5 (and is present) +priority: { $not: { $gt: 5, $lt: 10 } } # NOT (5 < priority < 10) +``` + +- Body must be an operator expression — a mapping of `$`-prefixed comparison/element operators on the field. Bare nested-field references inside the body are rejected (§4.2). +- Negates the inner field operator(s). +- For document-level negation (negating a whole-document predicate, including a graph anchor or compound `$or`), use `$nor: [filter]` — there is no document-level `$not`. Writing `$not:` at the filter root is a parse-time error with a hint pointing to `$nor`. ### 4.7 Comparison operators @@ -571,7 +585,7 @@ The language MUST express the following queries directly: ## 5. Graph operators -Graph operators live inside filter documents alongside frontmatter predicates. They share the predicate algebra of filter: AND-composed at top level, composable under `$and` / `$or` / `$not`, with the same operator-expression vocabulary as numeric frontmatter fields. Selection by graph relationship and selection by frontmatter content are written in the same filter document, distinguished only by whether the predicate key is a `$`-prefixed graph operator or a user frontmatter field name. The reserved-prefix rule (§2.3) makes this safe: user frontmatter fields cannot begin with `$`. +Graph operators live inside filter documents alongside frontmatter predicates. They share the predicate algebra of filter: AND-composed at top level, composable under `$and` / `$or` / `$nor`, with the same operator-expression vocabulary as numeric frontmatter fields. Selection by graph relationship and selection by frontmatter content are written in the same filter document, distinguished only by whether the predicate key is a `$`-prefixed graph operator or a user frontmatter field name. The reserved-prefix rule (§2.3) makes this safe: user frontmatter fields cannot begin with `$`. | Category | Operator | Predicate over... | |---|---|---| @@ -702,7 +716,7 @@ $referencedBy: { match: { $key: archive/index }, minDistance: 1, maxDistance: 3 #### 5.2.2 The `match` field -`match` is a filter document. It accepts the full filter language: bare frontmatter fields, `$`-prefixed filter operators (`$key`, `$or`, `$and`, `$not`, comparison operators, element operators, array operators), and **nested relational operators**. Nesting allows walks anchored at the result of another walk: +`match` is a filter document. It accepts the full filter language: bare frontmatter fields, `$`-prefixed filter operators (`$key`, `$or`, `$and`, `$nor`, comparison operators, element operators, array operators), and **nested relational operators**. Nesting allows walks anchored at the result of another walk: ```yaml $includedBy: @@ -809,7 +823,7 @@ filter: status: draft ``` -`$and` / `$or` / `$not` — the logical operators wrap any filter document, including ones containing these operators: +`$and` / `$or` / `$nor` — the logical operators wrap any filter document, including ones containing these operators: ```yaml filter: @@ -940,7 +954,7 @@ Implementation requirements: - **Disconnected graph** — walks operate per connected component; a walk anchored at K matches only documents reachable from K within bounds. - **Anchor exclusion** — a walk never matches a document in its anchor set. Use filter-level `$or` with `$key` (or with another predicate) to include the anchor set in the result. - **Default walk depth** — scalar-key shorthand fixes `maxDepth: 1` / `maxDistance: 1` (direct edges only). The full mapping form treats omitted `maxDepth` / `maxDistance` as unbounded; omitted `minDepth` / `minDistance` always default to 1. -- **Operators inside `$not`** — `$not: { $includedBy: { match: { $key: K }, maxDepth: 5 } }` matches documents that are *not* descendants of K within 5 levels. +- **Operators inside `$nor`** — `$nor: [{ $includedBy: { match: { $key: K }, maxDepth: 5 } }]` matches documents that are *not* descendants of K within 5 levels. ## 6. Projection @@ -1369,7 +1383,7 @@ The argument to `--filter` is parsed as a YAML value. If the parsed value is a m --filter '$key: notes/foo' # graph operator at top level ``` -The resulting filter document is parsed by the same builder that handles full operation documents, so all errors defined in §4 (mixed `$`/bare keys, double-`$not`, etc.) are surfaced verbatim. +The resulting filter document is parsed by the same builder that handles full operation documents, so all errors defined in §4 (mixed `$`/bare keys in field-value mappings, top-level `$not`, etc.) are surfaced verbatim. #### 12.2.2 Anchor depth syntax @@ -1540,7 +1554,7 @@ These flags predate the language and remain accepted on the commands they origin |---|---| | `--in KEY[:N]` | `--included-by KEY[:N]` | | `--in-any K1 --in-any K2` | `$or: [{ $includedBy: K1 }, { $includedBy: K2 }]` (scalar shorthand for each) | -| `--not-in KEY` | `$not: { $includedBy: KEY }` (scalar shorthand) | +| `--not-in KEY` | `$nor: [{ $includedBy: KEY }]` (scalar shorthand) | | `--refs-to KEY` | `$or: [{ $includes: KEY }, { $references: KEY }]` (scalar shorthand; legacy mixed-edge) | | `--refs-from KEY` | `$or: [{ $includedBy: KEY }, { $referencedBy: KEY }]` (scalar shorthand; legacy mixed-edge) | | `--keys` (on `delete`, `rename`, `extract`, `inline`) | `-f keys` | @@ -1560,11 +1574,11 @@ Within a single command: **`-k` / `$key` collision.** Combining `-k KEY` with a `--filter` whose top level contains a `$key` predicate is a CLI parse-time error: both clauses contribute to the document's key predicate, and silently AND-composing them would either produce a YAML mapping with two `$key` keys or quietly match the empty set. The error message points users at OR-composition (`--filter '$or: [{$key: a}, {$key: b}]'`) when they wanted a multi-key match, or at picking one source when they didn't. Multi-key match via `-k a -k b` (which lowers to `$key: { $in: [a, b] }`) remains valid. -For OR or NOT compositions, write the filter inside `--filter`: +For OR or NOR compositions, write the filter inside `--filter`: ``` --filter '$or: [{ status: draft }, { status: review }]' ---filter '$not: { status: archived }' +--filter '$nor: [{ status: archived }]' ``` ### 12.8 CLI examples @@ -2168,9 +2182,10 @@ logical_op ::= $and : [filter, ...] # non-empty | $or : [filter, ...] # non-empty | $nor : [filter, ...] # non-empty - | $not : filter # single filter, not list ``` +`$not` is field-level only; see `$_field_op` below. + #### A.2.2 Field operators ```