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
8 changes: 5 additions & 3 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,11 @@ types, dynamic config, validation gate) in the doc must be settled before B2.3.
`NeoReports.Sources.Join` package (IsPackable=false; license/distribution deferred to B2.3) —
one batched lookup per page, O(pageSize), no N+1; a standard `IBatchSource<TResult>` the pipeline
consumes unchanged. ✅ 2 green tests (batched-per-page with distinct keys; missing-key → default).
- [ ] **B2.2 — Keyset merge-join** (`Source.MergeJoin(left, right, on, map)`): a streaming merge of
two same-key-ordered sources; inner + left-outer; constant memory (bounded key group). Tests:
ordered-merge correctness, memory, Testcontainers E2E across two SQL sources.
- [x] **B2.2 — Keyset merge-join** (`Join.MergeJoin(left, keyLeft, right, keyRight, map, kind)`): an
`IStreamingSource<TResult>` merging two same-key-ordered sources; **inner + left-outer**; buffers
one right key-group at a time (constant memory when per-key multiplicity is bounded); the pipeline
slices the stream into batches. ✅ 4 green Join tests (inner drops unmatched, left-outer keeps them,
multi-page merge, correct grouping).
- [ ] **B2.3 — Package & docs + sample** `07-multi-source` (per the Pro/free decision).
- [ ] **B2.4 — Dynamic config** for multi-source (optional, later).

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using NeoReports.Abstractions;

namespace NeoReports.Sources.Join;

/// <summary>
/// A thin <see cref="IStreamingSource{T}"/> that produces its rows from a delegate — used by
/// <see cref="Join.MergeJoin{TLeft, TRight, TKey, TResult}"/> to expose the merge as a stream the
/// pipeline slices into batches.
/// </summary>
/// <typeparam name="TResult">The produced row type.</typeparam>
public sealed class DelegatingStreamingSource<TResult> : IStreamingSource<TResult>
{
private readonly Func<ReportExecutionContext, CancellationToken, IAsyncEnumerable<TResult>> _produce;

/// <summary>Creates the source.</summary>
/// <param name="schema">The declared schema (a placeholder; the pipeline projects via the builder's columns).</param>
/// <param name="produce">Produces the row stream for one execution.</param>
public DelegatingStreamingSource(
ReportSchema schema,
Func<ReportExecutionContext, CancellationToken, IAsyncEnumerable<TResult>> produce)
{
Schema = schema ?? throw new ArgumentNullException(nameof(schema));
_produce = produce ?? throw new ArgumentNullException(nameof(produce));
}

/// <inheritdoc />
public ReportSchema Schema { get; }

/// <inheritdoc />
public IAsyncEnumerable<TResult> ReadAsync(ReportExecutionContext execution, CancellationToken cancellationToken) =>
_produce(execution, cancellationToken);
}
124 changes: 124 additions & 0 deletions src/Sources/NeoReports.Sources.Join/Join.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using NeoReports.Abstractions;

namespace NeoReports.Sources.Join;

/// <summary>Fluent entry points for combining several sources into one.</summary>
public static class Join
{
/// <summary>
/// Keyset merge-join of two sources that are each ordered by the join key. Streams the merge —
/// for every left row it emits the (contiguous) group of right rows sharing its key — so memory
/// stays constant as long as a single key's right multiplicity is bounded. Both sources must be
/// ordered by their key (the same key domain), matching the v1 keyset ordering requirement.
/// </summary>
/// <typeparam name="TLeft">The left (driving) row type.</typeparam>
/// <typeparam name="TRight">The right row type.</typeparam>
/// <typeparam name="TKey">The join key type.</typeparam>
/// <typeparam name="TResult">The joined result row type.</typeparam>
/// <param name="left">The left source, ordered by <paramref name="keyLeft"/>.</param>
/// <param name="keyLeft">Extracts the key from a left row.</param>
/// <param name="right">The right source, ordered by <paramref name="keyRight"/>.</param>
/// <param name="keyRight">Extracts the key from a right row.</param>
/// <param name="map">Maps a left row plus its matched right rows (possibly empty) to a result.</param>
/// <param name="kind">Inner (default) drops unmatched left rows; LeftOuter keeps them with an empty group.</param>
/// <param name="keyComparer">Key comparer; defaults to <see cref="Comparer{T}.Default"/> (must match the sources' ordering).</param>
[SuppressMessage(
"Major Code Smell", "S2436:Types and methods should not have too many generic parameters",
Justification = "A join needs the left, right, key and result types — the same four-type shape as " +
"the BCL's Enumerable.GroupJoin<TOuter,TInner,TKey,TResult>.")]
public static IStreamingSource<TResult> MergeJoin<TLeft, TRight, TKey, TResult>(
IBatchSource<TLeft> left,
Func<TLeft, TKey> keyLeft,
IBatchSource<TRight> right,
Func<TRight, TKey> keyRight,
Func<TLeft, IReadOnlyList<TRight>, TResult> map,
JoinKind kind = JoinKind.Inner,
IComparer<TKey>? keyComparer = null)
{
ArgumentNullException.ThrowIfNull(left);
ArgumentNullException.ThrowIfNull(keyLeft);
ArgumentNullException.ThrowIfNull(right);
ArgumentNullException.ThrowIfNull(keyRight);
ArgumentNullException.ThrowIfNull(map);
IComparer<TKey> comparer = keyComparer ?? Comparer<TKey>.Default;

async IAsyncEnumerable<TResult> Merge(
ReportExecutionContext execution, [EnumeratorCancellation] CancellationToken cancellationToken)
{
await using IAsyncEnumerator<TRight> rightEnum =
Paginate(right, execution, cancellationToken).GetAsyncEnumerator(cancellationToken);
var rightHasCurrent = await rightEnum.MoveNextAsync().ConfigureAwait(false);

var group = new List<TRight>();
TRight[] current = Array.Empty<TRight>();
TKey? currentKey = default;
var haveKey = false;

await foreach (TLeft leftRow in Paginate(left, execution, cancellationToken).ConfigureAwait(false))
{
TKey key = keyLeft(leftRow);
if (!haveKey || comparer.Compare(currentKey!, key) != 0)
{
rightHasCurrent = await GatherGroupAsync(rightEnum, rightHasCurrent, key, keyRight, comparer, group)
.ConfigureAwait(false);
current = group.Count == 0 ? Array.Empty<TRight>() : group.ToArray();
currentKey = key;
haveKey = true;
}

if (current.Length > 0 || kind == JoinKind.LeftOuter)
yield return map(leftRow, current);
}
}

return new DelegatingStreamingSource<TResult>(left.Schema, Merge);
}

// Advances the right enumerator past keys below <paramref name="key"/>, then buffers the
// contiguous group of right rows whose key equals it. Returns whether the right enumerator still
// has a current row.
private static async Task<bool> GatherGroupAsync<TRight, TKey>(
IAsyncEnumerator<TRight> right,
bool hasCurrent,
TKey key,
Func<TRight, TKey> keyRight,
IComparer<TKey> comparer,
List<TRight> group)
{
group.Clear();
while (hasCurrent && comparer.Compare(keyRight(right.Current), key) < 0)
hasCurrent = await right.MoveNextAsync().ConfigureAwait(false);
while (hasCurrent && comparer.Compare(keyRight(right.Current), key) == 0)
{
group.Add(right.Current);
hasCurrent = await right.MoveNextAsync().ConfigureAwait(false);
}

return hasCurrent;
}

/// <summary>Reads a batch source page by page as a flat async sequence (O(pageSize) memory).</summary>
private static async IAsyncEnumerable<T> Paginate<T>(
IBatchSource<T> source, ReportExecutionContext execution, [EnumeratorCancellation] CancellationToken cancellationToken)
{
string? cursor = null;
var pageNumber = 0;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
pageNumber++;
BatchResult<T> result = await source
.ReadBatchAsync(new BatchContext(execution, 1000, cursor, pageNumber), cancellationToken)
.ConfigureAwait(false);

foreach (T record in result.Records)
yield return record;

if (!result.HasMore)
break;
cursor = result.NextCursor;
}
}
}
11 changes: 11 additions & 0 deletions src/Sources/NeoReports.Sources.Join/JoinKind.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace NeoReports.Sources.Join;

/// <summary>How unmatched left rows are treated in a merge-join.</summary>
public enum JoinKind
{
/// <summary>Only left rows that have at least one matching right row are emitted.</summary>
Inner,

/// <summary>Every left row is emitted; unmatched ones get an empty right group (left-outer join).</summary>
LeftOuter,
}
24 changes: 22 additions & 2 deletions src/Sources/NeoReports.Sources.Join/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# NeoReports.Sources.Join

Multi-source composition for NeoReports. **B2.1 — enrichment** is here; the keyset **merge-join**
(B2.2) follows.
Multi-source composition for NeoReports: **enrichment** (B2.1) and keyset **merge-join** (B2.2).

> Packaging and license (Pro vs free) are an open Epic B2 decision (**D29**), settled in B2.3. This
> package is not auto-published yet.
Expand All @@ -21,3 +20,24 @@ call per row), then each row is mapped with its looked-up value:

O(pageSize) memory; the batched-per-page shape structurally prevents the N+1 trap. The result is an
`IBatchSource<TResult>` the standard pipeline consumes unchanged.

## Keyset merge-join

Merge two sources that are each **ordered by the join key** (same key domain). For every left row it
emits the group of right rows sharing its key; `Inner` drops unmatched left rows, `LeftOuter` keeps
them with an empty group:

```csharp
.From(Join.MergeJoin(
left: Source.Sql(conn, sqlCustomers).Keyset<Customer, long>(c => c.Id),
keyLeft: c => c.Id,
right: Source.Sql(conn, sqlOrders).Keyset<Order, long>(o => o.CustomerId),
keyRight: o => o.CustomerId,
map: (c, orders) => new CustomerOrders(c, orders),
kind: JoinKind.LeftOuter))
```

Streams the merge — one right key-group buffered at a time — so memory stays constant as long as a
single key's right multiplicity is bounded. The result is an `IStreamingSource<TResult>` the pipeline
slices into batches.

99 changes: 99 additions & 0 deletions tests/NeoReports.Sources.Join.UnitTests/MergeJoinTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
using System.Globalization;
using Microsoft.Extensions.Logging.Abstractions;
using NeoReports.Abstractions;
using NeoReports.Sources.Join;
using Shouldly;
using Xunit;

namespace NeoReports.Sources.Join.UnitTests;

/// <summary>
/// B2.2: keyset merge-join of two same-key-ordered sources. For each left row it emits the group of
/// right rows sharing its key; inner drops unmatched left rows, left-outer keeps them with an empty
/// group. Sources page in small chunks here to exercise merging across page boundaries.
/// </summary>
public class MergeJoinTests
{
private static readonly long[] Matched = { 1, 3 };
private static readonly long[] All = { 1, 2, 3 };

private sealed record Customer(long Id, string Name);

private sealed record Order(long CustomerId, string Item);

private sealed record Row(long CustomerId, int OrderCount, string Items);

// customer 1 → 2 orders, customer 2 → none, customer 3 → 1 order (all ordered by key).
private static Paged<Customer> Customers() =>
new(new[] { new Customer(1, "A"), new Customer(2, "B"), new Customer(3, "C") }, pageSize: 2);

private static Paged<Order> Orders() =>
new(new[] { new Order(1, "x"), new Order(1, "y"), new Order(3, "z") }, pageSize: 2);

private static Row Map(Customer c, IReadOnlyList<Order> orders) =>
new(c.Id, orders.Count, string.Join(",", orders.Select(o => o.Item)));

private static async Task<List<Row>> CollectAsync(IStreamingSource<Row> source)
{
var rows = new List<Row>();
var exec = new ReportExecutionContext("job", "r", null, NullLogger.Instance, CancellationToken.None);
await foreach (Row row in source.ReadAsync(exec, CancellationToken.None))
rows.Add(row);
return rows;
}

[Fact]
public async Task Inner_join_emits_only_matched_left_rows_with_their_group()
{
IStreamingSource<Row> joined = Join.MergeJoin(Customers(), c => c.Id, Orders(), o => o.CustomerId, Map);

List<Row> rows = await CollectAsync(joined);

rows.Select(r => r.CustomerId).ShouldBe(Matched); // 2 has no orders → dropped
rows[0].OrderCount.ShouldBe(2);
rows[0].Items.ShouldBe("x,y"); // both right rows for key 1, in order, across the page boundary
rows[1].OrderCount.ShouldBe(1);
rows[1].Items.ShouldBe("z");
}

[Fact]
public async Task Left_outer_join_keeps_unmatched_left_rows_with_an_empty_group()
{
IStreamingSource<Row> joined =
Join.MergeJoin(Customers(), c => c.Id, Orders(), o => o.CustomerId, Map, JoinKind.LeftOuter);

List<Row> rows = await CollectAsync(joined);

rows.Select(r => r.CustomerId).ShouldBe(All);
rows.Single(r => r.CustomerId == 2).OrderCount.ShouldBe(0); // unmatched → empty group
}

/// <summary>Pages an ordered list in fixed-size chunks (cursor = next index), regardless of the requested page size.</summary>
private sealed class Paged<T> : IBatchSource<T>
{
private readonly IReadOnlyList<T> _rows;
private readonly int _pageSize;

public Paged(IReadOnlyList<T> rows, int pageSize)
{
_rows = rows;
_pageSize = pageSize;
}

public ReportSchema Schema { get; } = new(new[] { new ReportColumn("k", ColumnType.Integer) });

public Task<BatchResult<T>> ReadBatchAsync(BatchContext context, CancellationToken cancellationToken)
{
var start = context.Cursor is null ? 0 : int.Parse(context.Cursor, CultureInfo.InvariantCulture);
if (start >= _rows.Count)
return Task.FromResult(BatchResult<T>.Empty);

var take = Math.Min(_pageSize, _rows.Count - start);
var page = _rows.Skip(start).Take(take).ToArray();
var end = start + take;
var hasMore = end < _rows.Count;
var next = hasMore ? end.ToString(CultureInfo.InvariantCulture) : null;
return Task.FromResult(new BatchResult<T>(page, next, hasMore));
}
}
}