Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions crates/iwe/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion crates/iwe/help/export/after_help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
6 changes: 3 additions & 3 deletions crates/iwe/src/filter_args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
37 changes: 37 additions & 0 deletions crates/iwe/tests/cli_filter_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}

8 changes: 8 additions & 0 deletions crates/iwec/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions crates/iwec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
8 changes: 8 additions & 0 deletions crates/iwes/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions crates/liwe/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<path>'") 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
Expand Down
4 changes: 2 additions & 2 deletions crates/liwe/src/markdown/writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,13 +227,13 @@ impl MarkdownWriter {
) -> String {
let header_strs: Vec<String> = header
.iter()
.map(|c| inlines_to_markdown(c, &self.options))
.map(|c| inlines_to_markdown(c, &self.options).replace('|', "\\|"))
.collect();
let row_strs: Vec<Vec<String>> = 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();
Expand Down
124 changes: 65 additions & 59 deletions crates/liwe/src/query/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ pub enum ParseError {
MixedDollarAndBare {
path: Vec<String>,
},
TopLevelNotNotSupported {
path: Vec<String>,
},
UnknownOperator {
op: String,
path: Vec<String>,
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -398,49 +411,35 @@ fn build_filter(raw: RawFilter) -> Result<Filter, ParseError> {

fn build_filter_at(map: Mapping, path: &[String]) -> Result<Filter, ParseError> {
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<Filter> = Vec::with_capacity(dollar_keys.len() + bare_keys.len());

let mut clauses: Vec<Filter> = 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<String> = 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<Filter> = Vec::new();
for key_str in bare_keys {
let segments: Vec<String> = 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))
}
}

Expand All @@ -466,7 +465,9 @@ fn build_filter_op(op: &str, value: &Value, path: &[String]) -> Result<Filter, P
"$and" => 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")?))),
Expand Down Expand Up @@ -501,14 +502,6 @@ fn parse_filter_list(
.collect()
}

fn parse_not(value: &Value, path: &[String]) -> Result<Filter, ParseError> {
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",
Expand Down Expand Up @@ -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);
Expand Down
1 change: 0 additions & 1 deletion crates/liwe/src/query/document.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ pub enum Filter {
And(Vec<Filter>),
Or(Vec<Filter>),
Nor(Vec<Filter>),
Not(Box<Filter>),
Field { path: FieldPath, op: FieldOp },
Key(KeyOp),
Includes(Box<InclusionAnchor>),
Expand Down
7 changes: 0 additions & 7 deletions crates/liwe/src/query/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ fn eval(filter: &Filter, graph: &Graph, scope: Option<&HashSet<Key>>) -> 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),
Expand Down Expand Up @@ -89,12 +88,6 @@ fn eval_or(children: &[Filter], graph: &Graph, scope: Option<&HashSet<Key>>) ->
})
}

fn eval_not(inner: &Filter, graph: &Graph, scope: Option<&HashSet<Key>>) -> HashSet<Key> {
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<Key>>) -> HashSet<Key> {
let universe = scope.cloned().unwrap_or_else(|| all_keys(graph));
let union = eval_or(children, graph, Some(&universe));
Expand Down
8 changes: 4 additions & 4 deletions crates/liwe/src/query/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ mod tests {
}
fn and(filters: Vec<Filter>) -> Filter { Filter::And(filters) }
fn or(filters: Vec<Filter>) -> Filter { Filter::Or(filters) }
fn not(f: Filter) -> Filter { Filter::Not(Box::new(f)) }
fn nor(filters: Vec<Filter>) -> Filter { Filter::Nor(filters) }


fn doc(pairs: Vec<(&str, Value)>) -> Mapping {
Expand All @@ -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),
Expand Down Expand Up @@ -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);
Expand Down
Loading