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
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
Name: A search that excludes a folder no longer searches only that folder
Category: Fix
Description: Writing -path:Something in a query means "everywhere except there". The query was being read as the exact opposite — the excluded location became the only place searched — so the results were either rows from precisely the folder you excluded, or nothing at all, with no error either way.
Icon: Search
Order: -20260914
---

# A search that excludes a folder no longer searches only that folder

A query can exclude a location by prefixing it with a minus: `-path:Archive` means *everywhere
except Archive*, and `-path:Archive|Drafts` means *except either of those*.

**The exclusion was being read as its opposite.** The minus was dropped and the location kept, so
the query was treated as though it had asked for that location — and because a location is also what
decides *which store to look in*, the search was sent to the very partition it had been told to skip.
The result was one of two wrong answers: rows from exactly the folder you excluded, or nothing at
all. Neither raised an error, so a query that returned nothing read as "there is nothing there".

**A negated location is now a filter, not a destination.** `-path:Archive` narrows the rows —
"every row whose path is not Archive" — and the query looks wherever the rest of it says to look. A
query whose only location was the excluded one now says it is not specific enough, and asks for a
place to search or an explicit request to search everywhere, instead of silently searching the one
place that was ruled out.

Ordered comparisons on a location (`path:>"m"`, used for paging) were already treated this way for
the same reason. Ordinary positive forms — `path:Acme/Docs`, `path:A|B` — are unchanged.
24 changes: 19 additions & 5 deletions src/MeshWeaver.Mesh.Contract/Query/QueryParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -505,19 +505,33 @@ private string ParseSingleValue(string input, ref int i, bool stopAtParens = tru
// reported success). It also re-routed a mesh-wide query onto a single
// partition. Left as a filter token it becomes `n.path > 'm'` against the
// real, indexed `path` column and the walk pages properly.
// 🚨 AND SO IS A NEGATED ONE (#4296) — for the same reason, one step worse.
// `-path:A` / `-path:A|B` says "everywhere EXCEPT A", which is the one
// statement that can never be served by pinning to A. The single form fell
// through to `path = value` and the alternation set `Paths = Values` beside
// `Path = Values[0]`: the operator was dropped and the exclusion became a
// positive ANCHOR. `IsSufficientlySpecified()` then counted it as anchored,
// `ResolvePinnedPartition` pinned the query to A's schema, and the per-schema
// route applied `path IN (A, B)` — so the query returned rows from exactly the
// paths it meant to exclude, or nothing, with no error. Left as a filter token
// it becomes `n.path <> 'A'` / `n.path NOT IN (...)` against the real, indexed
// `path` column, and the query stays UNANCHORED unless something else anchors
// it — which is the truth about what it asked for.
if (token.Condition.Operator is QueryOperator.GreaterThan
or QueryOperator.GreaterOrEqual
or QueryOperator.LessThan
or QueryOperator.LessOrEqual)
or QueryOperator.LessOrEqual
or QueryOperator.NotEqual
or QueryOperator.NotIn)
{
filterTokens.Add(token);
continue;
}

// Multi-value `path:a|b|c` — `In`/`NotIn` carries the
// alternation list. Backends use the list to push down
// `WHERE path IN (...)`. Single-value form sets only Path.
if (token.Condition.Operator is QueryOperator.In or QueryOperator.NotIn
// Multi-value `path:a|b|c` — `In` carries the alternation list. Backends use
// the list to push down `WHERE path IN (...)`. Single-value form sets only
// Path. `NotIn` never arrives here: it is a filter, carved out above.
if (token.Condition.Operator is QueryOperator.In
&& token.Condition.Values.Length > 1)
{
paths = token.Condition.Values;
Expand Down
61 changes: 61 additions & 0 deletions test/MeshWeaver.Hosting.Test/ParsedQueryAnchoringTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,67 @@ public void ADeclaredFanOutIsSpecified_AndNamesNothing()
parsed.NamedPartitions().Should().BeEmpty("only the backend that ran it can state what a fan-out covered");
}

/// <summary>
/// 🚨 A NEGATED path is a FILTER, never an anchor (#4296). `-path:A` and `-path:A|B` say
/// "everywhere EXCEPT A" — the one statement that cannot be served by pinning to A. The parser
/// used to drop the operator and keep the value: the single form fell through to
/// <c>path = value</c> and the alternation set <c>Paths = Values</c> alongside
/// <c>Path = Values[0]</c>, so every consumer read the exclusion as a positive anchor,
/// <c>ResolvePinnedPartition</c> pinned the query to A's schema and the per-schema route applied
/// <c>path IN (A, B)</c> — returning rows from exactly the paths the caller excluded, or
/// nothing, with no error. Same class as the ordered comparisons carved out in #2186.
/// </summary>
[Theory]
[InlineData("-path:Acme/Secret nodeType:User")]
[InlineData("-path:Acme/Secret|Acme/Draft nodeType:User")]
public void ANegatedPathIsNeverAnAnchor(string query)
{
var parsed = Parse(query);

parsed.Path.Should().BeNull("an exclusion names no base path to walk from");
parsed.Paths.Should().BeNull("Paths is pushed down as `path IN (...)`, which is the opposite of the exclusion");
parsed.NamedPartitions().Should().BeEmpty("the excluded partition is not the partition to read");
parsed.IsSufficientlySpecified().Should().BeFalse(
$"'{query}' says where NOT to look, which never says where TO look — it needs an anchor or {ParsedQuery.CrossPartitionQualifier}");
}

/// <summary>
/// The other half: the exclusion must still be APPLIED. Dropping the token instead of filing it
/// as a filter would trade a wrong anchor for a query that quietly returns the excluded rows.
/// </summary>
[Theory]
[InlineData("-path:Acme/Secret namespace:Acme", QueryOperator.NotEqual, 1)]
[InlineData("-path:Acme/Secret|Acme/Draft namespace:Acme", QueryOperator.NotIn, 2)]
public void ANegatedPathSurvivesAsAFilterCondition(string query, QueryOperator expected, int values)
{
var parsed = Parse(query);

var condition = Conditions(parsed.Filter)
.Should().ContainSingle(c => c.Selector.Equals("path", StringComparison.OrdinalIgnoreCase))
.Subject;
condition.Operator.Should().Be(expected);
condition.Values.Should().HaveCount(values);
parsed.IsSufficientlySpecified().Should().BeTrue("namespace:Acme is what anchors this one");
}

/// <summary>A positive path is unaffected — the carve-out is for negation only.</summary>
[Fact]
public void APositivePathStillAnchors()
{
var parsed = Parse("path:Acme/Docs nodeType:User");

parsed.Path.Should().Be("Acme/Docs");
parsed.NamedPartitions().Should().Equal("Acme");
}

private static IEnumerable<QueryCondition> Conditions(QueryNode? node) => node switch
{
QueryComparison c => [c.Condition],
QueryAnd a => a.Children.SelectMany(Conditions),
QueryOr o => o.Children.SelectMany(Conditions),
_ => []
};

/// <summary>
/// The fixture's shared classifier reads the same definition — so the corpus tests that pin
/// the planner's verdict in MeshWeaver.Plugins pin this method, not a copy of it.
Expand Down
Loading