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
Expand Up @@ -560,6 +560,80 @@ await PopulateSearchableSchemas(
"the fan-out query must not be pinned to the first query's schema");
}

/// <summary>
/// Repro for the live memex-cloud defect (2026-07-19): a fan-out query whose nodeType
/// VALUE contains a slash (`nodeType:Edu/Exercise`, `nodeType:Store/Plugin`) returned zero
/// rows even though the rows exist. Exercises the exact production cross-schema fan-out.
/// </summary>
[Fact(Timeout = 60000)]
public async Task CrossSchema_SlashedNodeType_FoundAcrossPartitions()
{
var ct = TestContext.Current.CancellationToken;
var (_, partitions) = await SetupMultiOrgEnvironment(ct);

foreach (var (org, (_, adapter)) in partitions)
{
await adapter.WriteAsync(new MeshNode("Ex1", $"{org}/Course")
{
Name = $"{org} Exercise",
NodeType = "Edu/Exercise",
State = MeshNodeState.Active,
}, _options, ct);
}

var schemas = partitions.Keys.Select(k => k.ToLowerInvariant()).ToList();
var xsAdapter = new PostgreSqlStorageAdapter(_fixture.DataSource);
var query = new QueryParser().Parse("nodeType:Edu/Exercise");

var results = await xsAdapter.QueryNodesAcrossSchemasAsync(query, _options, schemas, ct: ct)
.Collect(ct).Should().Within(30.Seconds()).Emit();

results.Should().HaveCount(3, "each of the 3 partitions holds one Edu/Exercise node");
results.Select(n => n.NodeType).Distinct()
.Should().BeEquivalentTo(new[] { "Edu/Exercise" }, JsonSerializerOptions.Default);
}

/// <summary>
/// The full production fan-out orchestrator (PostgreSqlPartitionedMeshQuery) for the same
/// slashed-nodeType query — no path, no namespace → genuine cross-schema fan-out.
/// </summary>
[Fact(Timeout = 60000)]
public async Task PartitionedQuery_SlashedNodeTypeFanOut_ReturnsRows()
{
var ct = TestContext.Current.CancellationToken;
var (_, partitions) = await SetupMultiOrgEnvironment(ct);

foreach (var (org, (_, adapter)) in partitions)
{
await adapter.WriteAsync(new MeshNode("Ex1", $"{org}/Course")
{
Name = $"{org} Exercise",
NodeType = "Edu/Exercise",
State = MeshNodeState.Active,
}, _options, ct);
}
await PopulateSearchableSchemas(
partitions.Keys.Select(k => k.ToLowerInvariant()), ct);

using var provider = CreatePartitionProvider();
var query = new PostgreSqlPartitionedMeshQuery(
new PostgreSqlCrossSchemaQueryProvider(_fixture.DataSource),
partitionProvider: provider);

var request = MeshQueryRequest.FromQuery("nodeType:Edu/Exercise", WellKnownUsers.System);

var initial = await query.Query<MeshNode>(request, _options)
.Where(c => c.ChangeType == QueryChangeType.Initial)
.FirstAsync()
.Timeout(TimeSpan.FromSeconds(30))
.ToTask(ct);

var paths = initial.Items.Select(n => n.Path).ToList();
paths.Should().Contain("ACME/Course/Ex1");
paths.Should().Contain("FutuRe/Course/Ex1");
paths.Should().Contain("Contoso/Course/Ex1");
}

private PostgreSqlPartitionStorageProvider CreatePartitionProvider()
=> new(
_fixture.DataSource,
Expand Down
59 changes: 59 additions & 0 deletions test/MeshWeaver.Hosting.PostgreSql.Test/QuerySyntaxTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,65 @@ public async Task Sort_LowerOfName_CaseInsensitiveAscending()

#endregion

#region Slashed nodeType values (Edu/Exercise, Store/Plugin)

private async Task SeedSlashedTypes()
{
await _fixture.CleanData().Should().Within(60.Seconds()).Emit();
var adapter = _fixture.StorageAdapter;
var ac = _fixture.AccessControl;
var ct = TestContext.Current.CancellationToken;

await adapter.Write(new MeshNode("Ex1", "ACME/Course")
{
Name = "Exercise One",
NodeType = "Edu/Exercise",
}, _options).Should().Within(30.Seconds()).Emit();

await adapter.Write(new MeshNode("Ex2", "ACME/Course")
{
Name = "Exercise Two",
NodeType = "Edu/Exercise",
}, _options).Should().Within(30.Seconds()).Emit();

await adapter.Write(new MeshNode("Plug1", "ACME/Store")
{
Name = "A Plugin",
NodeType = "Store/Plugin",
}, _options).Should().Within(30.Seconds()).Emit();

await ac.Grant("ACME", "Anonymous", "Read", isAllow: true, ct).Should().Within(30.Seconds()).Emit();
}

[Fact]
public async Task SlashedNodeType_ScopedFilter_MatchesRows()
{
await SeedSlashedTypes();
var query = new PostgreSqlMeshQuery(_fixture.StorageAdapter);
var request = MeshQueryRequest.FromQuery("nodeType:Edu/Exercise path:ACME scope:descendants");

var results = await CollectResults(query, request);

results.Should().HaveCount(2);
results.Select(n => n.NodeType).Distinct()
.Should().BeEquivalentTo(new[] { "Edu/Exercise" }, JsonSerializerOptions.Default);
}

[Fact]
public async Task SlashedNodeType_StorePlugin_MatchesRow()
{
await SeedSlashedTypes();
var query = new PostgreSqlMeshQuery(_fixture.StorageAdapter);
var request = MeshQueryRequest.FromQuery("nodeType:Store/Plugin");

var results = await CollectResults(query, request);

results.Should().HaveCount(1);
results[0].Name.Should().Be("A Plugin");
}

#endregion

private async Task<List<MeshNode>> CollectResults(PostgreSqlMeshQuery query, MeshQueryRequest request)
=> (await query.QueryList(request, _options, TestContext.Current.CancellationToken)
.Should().Within(30.Seconds()).Emit())
Expand Down
16 changes: 16 additions & 0 deletions test/MeshWeaver.Query.Test/QueryParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,22 @@ public void Parse_PathValueWithSlash_ParsesCorrectly()
comparison.Condition.Value.Should().Be("ACME/Project/Todo");
}

[Theory]
[InlineData("nodeType:Edu/Exercise", "Edu/Exercise")]
[InlineData("nodeType:Store/Plugin", "Store/Plugin")]
[InlineData("nodeType:Store/Catalog", "Store/Catalog")]
public void Parse_SlashedNodeTypeValue_KeepsFullValue(string query, string expected)
{
var result = _parser.Parse(query);

result.Filter.Should().BeOfType<QueryComparison>();
var comparison = (QueryComparison)result.Filter!;
comparison.Condition.Selector.Should().Be("nodeType");
comparison.Condition.Operator.Should().Be(QueryOperator.Equal);
comparison.Condition.Value.Should().Be(expected);
result.TextSearch.Should().BeNull("the slashed value is a literal, not a free-text token");
}

#endregion

#region Namespace Parameter
Expand Down
Loading