Skip to content

Commit

Permalink
Add DataChanged event to Query
Browse files Browse the repository at this point in the history
  • Loading branch information
Jcparkyn committed May 4, 2024
1 parent c06a003 commit 6fcaf3c
Show file tree
Hide file tree
Showing 7 changed files with 166 additions and 33 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

## Added
- The non-generic `QueryOptions` class can now be implicitly cast to `QueryOptions<TArg, TResult>`.
- New `DataChanged` event for queries, which is triggered whenever the `Data` property changes.

## Changed
- **BREAKING CHANGE**: If a query succeeds and then fails on a refetch, `query.Data` now returns `null`/`default`, instead of the old data. Also, `query.LastData` now returns the data from the last successful request (even if the arg was different) instead of `null`/`default`.
Expand Down
42 changes: 40 additions & 2 deletions src/Phetch.Core/Query.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
namespace Phetch.Core;

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
Expand Down Expand Up @@ -106,6 +107,11 @@ public interface IQuery : IDisposable
/// </remarks>
public interface IQuery<TArg, TResult> : IQuery
{
/// <summary>
/// An event that fires whenever the data of this query changes (including when loading is started).
/// </summary>
public event Action<TResult?>? DataChanged;

/// <summary>
/// An event that fires whenever this query succeeds.
/// </summary>
Expand Down Expand Up @@ -208,10 +214,14 @@ public class Query<TArg, TResult> : IQuery<TArg, TResult>
private readonly TimeSpan _staleTime;
private FixedQuery<TArg, TResult>? _lastSuccessfulQuery;
private FixedQuery<TArg, TResult>? _currentQuery;
private TResult? _oldData; // used for generating DataChanged events

/// <inheritdoc/>
public event Action? StateChanged;

/// <inheritdoc/>
public event Action<TResult?>? DataChanged;

/// <inheritdoc/>
public event Action<QuerySuccessEventArgs<TArg, TResult>>? Succeeded;

Expand All @@ -229,6 +239,7 @@ internal Query(
_cache = cache;
_options = options;
_staleTime = options?.StaleTime ?? endpointOptions.DefaultStaleTime;
DataChanged += options?.OnDataChanged;
Succeeded += options?.OnSuccess;
Failed += options?.OnFailure;
}
Expand Down Expand Up @@ -330,7 +341,9 @@ public async Task<TResult> SetArgAsync(TArg arg)
(newQuery.Status == QueryStatus.Error || newQuery.IsStaleByTime(_staleTime, DateTime.Now));
if (shouldRefetch)
{
return await newQuery.RefetchAsync(_options?.RetryHandler).ConfigureAwait(false);
var task = newQuery.RefetchAsync(_options?.RetryHandler).ConfigureAwait(false);
CheckDataChanged();
return await task;
}
else
{
Expand All @@ -340,6 +353,7 @@ public async Task<TResult> SetArgAsync(TArg arg)
Succeeded?.Invoke(new(arg, newQuery.Data!));
}
StateChanged?.Invoke();
CheckDataChanged();
}
}
Debug.Assert(newQuery.LastInvocation is not null, "newQuery should have been invoked before this point");
Expand All @@ -354,25 +368,41 @@ public async Task<TResult> TriggerAsync(TArg arg)
_currentQuery?.RemoveObserver(this);
query.AddObserver(this);
_currentQuery = query;
return await query.RefetchAsync(_options?.RetryHandler).ConfigureAwait(false);
var task = query.RefetchAsync(_options?.RetryHandler).ConfigureAwait(false);
CheckDataChanged();
return await task;
}

internal void OnQuerySuccess(QuerySuccessEventArgs<TArg, TResult> args)
{
_lastSuccessfulQuery = _currentQuery;
Succeeded?.Invoke(args);
StateChanged?.Invoke();
CheckDataChanged();
}

internal void OnQueryFailure(QueryFailureEventArgs<TArg> args)
{
Failed?.Invoke(args);
StateChanged?.Invoke();
CheckDataChanged();
}

internal void OnQueryUpdate()
{
StateChanged?.Invoke();
CheckDataChanged();
}

private void CheckDataChanged()
{
// This is a bit hacky, but much simpler and more reliable than doing separate checks every
// time data could change.
if (!Equals(_oldData, Data))
{
_oldData = Data;
DataChanged?.Invoke(Data);
}
}

/// <summary>
Expand Down Expand Up @@ -404,6 +434,14 @@ public void Dispose()
Dispose(disposing: true);
GC.SuppressFinalize(this);
}

private static bool Equals(TResult? a, TResult? b)
{
// Normal == doesn't work for generics, and EqualityComparer isn't guaranteed to handle nulls.
if (a is null && b is null) return true;
if (a is null || b is null) return false;
return EqualityComparer<TResult>.Default.Equals(a, b);
}
}

/// <summary>
Expand Down
18 changes: 17 additions & 1 deletion src/Phetch.Core/QueryOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public sealed record QueryOptions()
/// </list>
/// If you want to call a function <b>every</b> time a query succeeds (e.g., for invalidating a
/// cache), use <see cref="EndpointOptions.OnSuccess"/> when creating an endpoint.
/// <para/>
/// This is also not called if the query is already cached and the cached data is used instead.
/// If you want to call a function every time the query data changes, use <see cref="OnDataChanged"/>.
/// </remarks>
public Action<EventArgs>? OnSuccess { get; init; }

Expand All @@ -68,6 +71,15 @@ public sealed record QueryOptions()
/// </remarks>
public Action<QueryFailureEventArgs>? OnFailure { get; init; }

/// <summary>
/// A function that gets run when this query's data changes. Unlike <see cref="OnSuccess"/>,
/// this is called if the query result is already cached.
/// </summary>
/// <remarks>
/// Like <see cref="OnSuccess"/>, this is only called if the query is currently being observed.
/// </remarks>
public Action? OnDataChanged { get; init; }

/// <summary>
/// If set, overrides the default RetryHandler for the endpoint.
/// <para/>
Expand All @@ -83,14 +95,15 @@ public sealed record QueryOptions()
public sealed record QueryOptions<TArg, TResult>()
{
/// <summary>
/// Creates a strongly-typed QueryOptions&lt;TArg, TResult&gt; from a QueryOptions instance.
/// Creates a strongly-typed <see cref="QueryOptions{TArg, TResult}"/> from a <see cref="QueryOptions"/> instance.
/// </summary>
public QueryOptions(QueryOptions original) : this()
{
_ = original ?? throw new ArgumentNullException(nameof(original));
StaleTime = original.StaleTime;
OnSuccess = original.OnSuccess;
OnFailure = original.OnFailure;
OnDataChanged = _ => original.OnDataChanged?.Invoke();
RetryHandler = original.RetryHandler;
}

Expand All @@ -111,6 +124,9 @@ public QueryOptions(QueryOptions original) : this()
/// <inheritdoc cref="QueryOptions.OnFailure"/>
public Action<QueryFailureEventArgs<TArg>>? OnFailure { get; init; }

/// <inheritdoc cref="QueryOptions.OnDataChanged"/>
public Action<TResult>? OnDataChanged { get; init; }

/// <summary>
/// If set, overrides the default RetryHandler for the endpoint.
/// <para/>
Expand Down
14 changes: 12 additions & 2 deletions test/Phetch.Tests/Endpoint/EndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,21 @@ public async Task Should_share_cache_between_queries()
query1Mon.OccurredEvents.Should().SatisfyRespectively(
e => e.EventName.Should().Be("StateChanged"),
e => e.EventName.Should().Be("Succeeded"),
e => e.EventName.Should().Be("StateChanged")
e => e.EventName.Should().Be("StateChanged"),
e =>
{
e.EventName.Should().Be("DataChanged");
e.Parameters.Should().Equal("10");
}
);
query2Mon.OccurredEvents.Should().SatisfyRespectively(
e => e.EventName.Should().Be("Succeeded"),
e => e.EventName.Should().Be("StateChanged")
e => e.EventName.Should().Be("StateChanged"),
e =>
{
e.EventName.Should().Be("DataChanged");
e.Parameters.Should().Equal("10");
}
);
onSuccessCalls.Should().Equal("10");
}
Expand Down
21 changes: 21 additions & 0 deletions test/Phetch.Tests/Endpoint/UpdateQueryDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ public async Task UpdateQueryData_should_work()
}
}

[UIFact]
public async Task UpdateQueryData_should_trigger_events()
{
var endpoint = new Endpoint<int, string>(
val => TestHelpers.ReturnAsync(val.ToString())
);
var query = endpoint.Use();
await query.SetArgAsync(1);

var mon = query.Monitor();
endpoint.UpdateQueryData(1, "1 - test1");
mon.OccurredEvents.Should().SatisfyRespectively(
e => e.EventName.Should().Be("StateChanged"),
e =>
{
e.EventName.Should().Be("DataChanged");
e.Parameters.Should().Equal("1 - test1");
}
);
}

[UIFact]
public async Task UpdateQueryData_should_affect_triggered_query()
{
Expand Down
22 changes: 19 additions & 3 deletions test/Phetch.Tests/Query/QueryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@ public async Task SetArg_should_set_loading_states_correctly_for_same_arg()
mon.OccurredEvents.Should().SatisfyRespectively(
e => e.EventName.Should().Be("StateChanged"),
e => e.EventName.Should().Be("Succeeded"),
e => e.EventName.Should().Be("StateChanged")
e => e.EventName.Should().Be("StateChanged"),
e =>
{
e.EventName.Should().Be("DataChanged");
e.Parameters.Should().Equal("test");
}
);
mon.Clear();

Expand Down Expand Up @@ -115,7 +120,8 @@ public async Task SetArg_should_set_loading_states_correctly_for_different_arg()
mon.OccurredEvents.Should().SatisfyRespectively(
e => e.EventName.Should().Be("StateChanged"),
e => e.EventName.Should().Be("Succeeded"),
e => e.EventName.Should().Be("StateChanged")
e => e.EventName.Should().Be("StateChanged"),
e => e.EventName.Should().Be("DataChanged")
);
mon.Clear();

Expand All @@ -139,8 +145,18 @@ public async Task SetArg_should_set_loading_states_correctly_for_different_arg()

mon.OccurredEvents.Should().SatisfyRespectively(
e => e.EventName.Should().Be("StateChanged"),
e =>
{
e.EventName.Should().Be("DataChanged");
e.Parameters.Should().Equal([null]); // Normal params syntax breaks something here, so use an explicit array.
},
e => e.EventName.Should().Be("Succeeded"),
e => e.EventName.Should().Be("StateChanged")
e => e.EventName.Should().Be("StateChanged"),
e =>
{
e.EventName.Should().Be("DataChanged");
e.Parameters.Should().Equal("two");
}
);
mon.Clear();
}
Expand Down
81 changes: 56 additions & 25 deletions test/Phetch.Tests/Query/TriggerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,45 +11,76 @@ public class TriggerTests
[UIFact]
public async Task Should_set_loading_states_correctly()
{
var tcs = new TaskCompletionSource<int>();
var mut = new Endpoint<int, int>(
(val, _) => tcs.Task
).Use();
var qf = new MockQueryFunction<int, string>(2);
var query = new Endpoint<int, string>(qf.Query).Use();
var mon = query.Monitor();

mut.Status.Should().Be(QueryStatus.Idle);
mut.IsUninitialized.Should().BeTrue();
mut.HasData.Should().BeFalse();
query.Status.Should().Be(QueryStatus.Idle);
query.IsUninitialized.Should().BeTrue();
query.HasData.Should().BeFalse();

// Fetch once
var triggerTask1 = mut.TriggerAsync(10);
var triggerTask1 = query.TriggerAsync(10);

mut.IsLoading.Should().BeTrue();
mut.IsFetching.Should().BeTrue();
mon.OccurredEvents.Should().SatisfyRespectively(
e => e.EventName.Should().Be("StateChanged")
);
mon.Clear();

query.IsLoading.Should().BeTrue();
query.IsFetching.Should().BeTrue();

await Task.Yield();
tcs.SetResult(11);
qf.Sources[0].SetResult("10");
var result1 = await triggerTask1;

result1.Should().Be(11);
mut.Status.Should().Be(QueryStatus.Success);
mut.IsSuccess.Should().BeTrue();
mut.IsLoading.Should().BeFalse();
mut.Data.Should().Be(11);
mut.HasData.Should().BeTrue();
result1.Should().Be("10");
query.Status.Should().Be(QueryStatus.Success);
query.IsSuccess.Should().BeTrue();
query.IsLoading.Should().BeFalse();
query.Data.Should().Be("10");
query.HasData.Should().BeTrue();

mon.OccurredEvents.Should().SatisfyRespectively(
e => e.EventName.Should().Be("Succeeded"),
e => e.EventName.Should().Be("StateChanged"),
e =>
{
e.EventName.Should().Be("DataChanged");
e.Parameters.Should().Equal("10");
});
mon.Clear();

tcs = new();
// Fetch again
var triggerTask2 = mut.TriggerAsync(20);
var triggerTask2 = query.TriggerAsync(20);

mut.Status.Should().Be(QueryStatus.Loading);
mut.IsLoading.Should().BeTrue();
mut.IsFetching.Should().BeTrue();
query.Status.Should().Be(QueryStatus.Loading);
query.IsLoading.Should().BeTrue();
query.IsFetching.Should().BeTrue();

mon.OccurredEvents.Should().SatisfyRespectively(
e => e.EventName.Should().Be("StateChanged"),
e =>
{
e.EventName.Should().Be("DataChanged");
e.Parameters.Should().Equal([null]);
});
mon.Clear();

tcs.SetResult(21);
qf.Sources[1].SetResult("20");
var result2 = await triggerTask2;

result2.Should().Be(21);
mut.IsLoading.Should().BeFalse();
mon.OccurredEvents.Should().SatisfyRespectively(
e => e.EventName.Should().Be("Succeeded"),
e => e.EventName.Should().Be("StateChanged"),
e =>
{
e.EventName.Should().Be("DataChanged");
e.Parameters.Should().Equal("20");
});

result2.Should().Be("20");
query.IsLoading.Should().BeFalse();
}

[UIFact]
Expand Down

0 comments on commit 6fcaf3c

Please sign in to comment.