wip composing threads - nested service provider is busted

This commit is contained in:
Vivian Lim 2023-06-13 01:38:19 -07:00
parent 806bab368e
commit 7b401d84ea
9 changed files with 273 additions and 41 deletions

View File

@ -1,7 +1,11 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using PostLab.Data.Model.Abstract;
using PostLab.Data.Model.Forum;
using PostLab.Data.Model.Framework;
using PostLab.Data.Services.Live;
using PostLab.Data.Services.Realms;
using PostLab.Pages.Controls;
using PostLab.Util;
namespace PostLab.Data.Services.Forum
@ -14,10 +18,12 @@ namespace PostLab.Data.Services.Forum
private ForumBoard boardData;
private IServiceProvider serviceProvider;
public Guid Id => boardData.Id;
public string Name => boardData.Name;
public ActiveBoard(IDbContextFactory<ApplicationDbContext> dbContext, Guid boardId)
public ActiveBoard(IDbContextFactory<ApplicationDbContext> dbContext, Guid boardId, IServiceProvider serviceProvider)
{
this.dbContext = new AsyncMonitor<ApplicationDbContext>(dbContext.CreateDbContext());
this.threadsChannel = new ThreadCollectionChannel(this.dbContext);
@ -25,6 +31,58 @@ namespace PostLab.Data.Services.Forum
boardData = context.Value.ForumBoards.Where(b => b.Id == boardId).FirstOrDefault() ?? throw new ArgumentException("board doesn't exist");
}
public async Task NewThreadAsync(NewThreadModel model)
{
var globalServices = serviceProvider.GetRequiredService<GlobalServices>();
var userProfileService = globalServices.ServiceProvider.GetRequiredService<UserProfileService>();
var posts = new List<ForumPost>();
using (var context = dbContext.Obtain())
{
var user = context.Value.Profiles.Where(p => p.Id == userProfileService.ActiveProfile.Id).FirstOrDefault() ?? throw new InvalidOperationException("User id doesn't exist");
var content = new RichContent()
{
Content = model.Content,
ContentType = ContentType.Markdown,
CreatedUtc = ConstructionUtil.GetDateTimeUtc(),
Id = ConstructionUtil.NewId(),
OwnerId = userProfileService.ActiveProfile.Id,
Owner = user
};
var op = new ForumPost
{
Id = ConstructionUtil.NewId(),
CreatedUtc = ConstructionUtil.GetDateTimeUtc(),
OwnerId = userProfileService.ActiveProfile.Id,
Owner = user,
ReplyTo = null,
History = new List<Versioned<RichContent>.TrackedVersion>()
{
new()
{
Id = ConstructionUtil.NewId(),
CreatedUtc = ConstructionUtil.GetDateTimeUtc(),
Content = content,
}
},
};
posts.Add(op);
}
await threadsChannel.CreateAsync(new ForumThread
{
Title = model.Title,
Id = ConstructionUtil.NewId(),
CreatedUtc = ConstructionUtil.GetDateTimeUtc(),
Posts = posts
});
}
public class ThreadCollectionChannel : PersistentEntityCollectionChannel<ForumThread>
{
public ThreadCollectionChannel(AsyncMonitor<ApplicationDbContext> dbContext) : base(dbContext)
@ -66,5 +124,12 @@ namespace PostLab.Data.Services.Forum
return await context.ForumThreads.ToListAsync();
}
}
public class NewThreadModel : Composer.IComposerModel
{
public string Title { get; set; }
public string Content { get; set; }
}
}
}

View File

@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Internal;
using PostLab.Data.Model.Forum;
using PostLab.Data.Services.Realms;
using PostLab.Data.Services.Live;
using PostLab.Util;
namespace PostLab.Data.Services.Forum
@ -14,15 +14,18 @@ namespace PostLab.Data.Services.Forum
private ForumThread threadData;
private IServiceProvider serviceProvider;
public Guid Id => threadData.Id;
public string Title => threadData.Title;
public ActiveThread(IDbContextFactory<ApplicationDbContext> dbContext, Guid threadId)
public ActiveThread(IDbContextFactory<ApplicationDbContext> dbContext, Guid threadId, IServiceProvider serviceProvider)
{
this.dbContext = new AsyncMonitor<ApplicationDbContext>(dbContext.CreateDbContext());
this.postsChannel = new PostsCollectionChannel(this.dbContext, threadId);
using var context = this.dbContext.Obtain();
threadData = context.Value.ForumThreads.Where(t => t.Id == threadId).FirstOrDefault() ?? throw new ArgumentException("thread doesn't exist");
this.serviceProvider = serviceProvider;
}
public class PostsCollectionChannel : PersistentEntityCollectionChannel<ForumPost>
@ -67,7 +70,7 @@ namespace PostLab.Data.Services.Forum
protected override Task<IEnumerable<ForumPost>> InnerReadAllAsync(ApplicationDbContext context)
{
var thread = context.ForumThreads.Where(t => t.Id == threadId).FirstOrDefault() ?? throw new InvalidOperationException("thread doesn't exist?");
return Task.FromResult(thread.Posts as IEnumerable<ForumPost>);
return Task.FromResult(thread.Posts as IEnumerable<ForumPost> ?? throw new InvalidOperationException("thread is missing posts?"));
}
}
}

View File

@ -0,0 +1,26 @@
using PostLab.Data.Model.Abstract;
namespace PostLab.Data.Services.Live
{
public record EntityNotification<T> where T: TimestampedUnique
{
public required Guid Id { get; init; }
public required DateTime WhenUtc { get; init; }
public required SnapshotId SnapshotId { get; init; }
}
public record AddEntityNotification<T> : EntityNotification<T> where T: TimestampedUnique
{
public required T Value { get; init; }
}
public record UpdateEntityNotification<T> : EntityNotification<T> where T: TimestampedUnique
{
public Guid OldId { get; init; }
public required T NewValue { get; init; }
}
public record DeleteEntityNotification<T> : EntityNotification<T> where T: TimestampedUnique
{
}
}

View File

@ -0,0 +1,82 @@
using PostLab.Data.Model.Abstract;
using PostLab.Util;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Threading.Channels;
namespace PostLab.Data.Services.Live
{
public class ObservableCollectionEntityNotificationReader<T>: INotifyCollectionChanged, INotifyPropertyChanged, IReadOnlyCollection<T> where T: TimestampedUnique
{
private readonly ChannelReader<EntityNotification<T>> reader;
private readonly Action stateChangedCallback;
private ImmutableArray<T> items = new();
private ObservableCollectionEntityNotificationReader(IEnumerable<T> initialItems, ChannelReader<EntityNotification<T>> reader, Action stateChangedCallback)
{
this.reader = reader;
this.stateChangedCallback = stateChangedCallback;
items = initialItems.ToImmutableArray();
}
public int Count => ((IReadOnlyCollection<T>)items).Count;
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public event PropertyChangedEventHandler? PropertyChanged;
public static async Task<ObservableCollectionEntityNotificationReader<T>> CreateAsync(IEnumerable<T> initialItems, ChannelReader<EntityNotification<T>> reader, Action stateChangedCallback)
{
var result = new ObservableCollectionEntityNotificationReader<T>(initialItems, reader, stateChangedCallback);
await result.StartReadingChannelAsync();
return result;
}
public IEnumerator<T> GetEnumerator()
{
return ((IEnumerable<T>)items).GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return ((IEnumerable)items).GetEnumerator();
}
private async Task StartReadingChannelAsync()
{
var _ = Task.Run(async () =>
{
while (true)
{
var notification = await reader.ReadAsync();
switch (notification)
{
case AddEntityNotification<T> add:
items = items.Add(add.Value);
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, add.Value));
break;
case UpdateEntityNotification<T> update:
var oldValue = items.First(x => x.Id == update.OldId);
items = items.Replace(oldValue, update.NewValue);
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, update.NewValue, update.OldId));
break;
case DeleteEntityNotification<T> delete:
var removedValue = items.First(x => x.Id == delete.Id);
items = items.Remove(removedValue);
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, delete.Id));
break;
default:
throw new InvalidOperationException($"Unknown notification type {notification.GetType().Name}");
}
}
});
}
}
}

View File

@ -2,7 +2,7 @@
using PostLab.Util;
using System.Threading.Channels;
namespace PostLab.Data.Services.Realms
namespace PostLab.Data.Services.Live
{
/// <summary>
/// Notifies of updates and manages a long-lived DB context.
@ -10,9 +10,9 @@ namespace PostLab.Data.Services.Realms
/// <typeparam name="T"></typeparam>
public abstract class PersistentEntityCollectionChannel<T> where T : TimestampedUnique
{
private Channel<EntityNotification> notificationChannels = Channel.CreateUnbounded<EntityNotification>();
public ChannelReader<EntityNotification> Reader => notificationChannels.Reader;
private Channel<EntityNotification<T>> notificationChannels = Channel.CreateUnbounded<EntityNotification<T>>();
public ChannelReader<EntityNotification<T>> Reader => notificationChannels.Reader;
private int snapshotId = 0;
private readonly AsyncMonitor<ApplicationDbContext> dbContext;
@ -20,7 +20,7 @@ namespace PostLab.Data.Services.Realms
{
this.dbContext = dbContext;
}
private SnapshotId NextSnapshotId()
{
return new SnapshotId(Interlocked.Increment(ref snapshotId));
@ -31,7 +31,7 @@ namespace PostLab.Data.Services.Realms
using var context = await dbContext.ObtainAsync();
await CommitCreateAsync(context.Value, newValue);
await notificationChannels.Writer.WriteAsync(new AddEntityNotification
await notificationChannels.Writer.WriteAsync(new AddEntityNotification<T>
{
Id = newValue.Id,
WhenUtc = newValue.CreatedUtc,
@ -45,7 +45,7 @@ namespace PostLab.Data.Services.Realms
using var context = await dbContext.ObtainAsync();
await CommitUpdateAsync(context.Value, oldId, newValue);
await notificationChannels.Writer.WriteAsync(new UpdateEntityNotification()
await notificationChannels.Writer.WriteAsync(new UpdateEntityNotification<T>()
{
Id = newValue.Id,
WhenUtc = newValue.CreatedUtc,
@ -60,7 +60,7 @@ namespace PostLab.Data.Services.Realms
using var context = await dbContext.ObtainAsync();
await CommitDeleteAsync(context.Value, targetId);
await notificationChannels.Writer.WriteAsync(new DeleteEntityNotification()
await notificationChannels.Writer.WriteAsync(new DeleteEntityNotification<T>()
{
Id = targetId,
WhenUtc = DateTime.UtcNow,
@ -79,27 +79,6 @@ namespace PostLab.Data.Services.Realms
protected abstract Task<bool> CommitDeleteAsync(ApplicationDbContext context, Guid targetId);
protected abstract Task<IEnumerable<T>> InnerReadAllAsync(ApplicationDbContext context);
public record EntityNotification
{
public required Guid Id { get; init; }
public required DateTime WhenUtc { get; init; }
public required SnapshotId SnapshotId { get; init; }
}
public record AddEntityNotification : EntityNotification
{
public required T Value { get; init; }
}
public record UpdateEntityNotification : EntityNotification
{
public Guid OldId { get; init; }
public required T NewValue { get; init; }
}
public record DeleteEntityNotification : EntityNotification
{
}
}
public record SnapshotId(int id);

View File

@ -9,7 +9,7 @@ namespace PostLab.Data.Services.Realms
{
public class ActiveRealm
{
public static async Task<ActiveRealm?> ActivateRealm(IDbContextFactory<ApplicationDbContext> dbContextFactory, Guid realmId)
public static async Task<ActiveRealm?> ActivateRealm(IDbContextFactory<ApplicationDbContext> dbContextFactory, Guid realmId, IServiceProvider globalServiceProvider)
{
using var context = await dbContextFactory.CreateDbContextAsync();
var realm = await context.Realm.FindAsync(realmId);
@ -19,7 +19,7 @@ namespace PostLab.Data.Services.Realms
return null;
}
var activeRealm = new ActiveRealm(dbContextFactory, realm);
var activeRealm = new ActiveRealm(dbContextFactory, realm, new GlobalServices(globalServiceProvider));
await activeRealm.realmServiceHost.StartAsync();
@ -32,17 +32,19 @@ namespace PostLab.Data.Services.Realms
public string Url => $"/realm/{Id}";
private ActiveRealm(IDbContextFactory<ApplicationDbContext> dbContextFactory, Realm realmData)
private ActiveRealm(IDbContextFactory<ApplicationDbContext> dbContextFactory, Realm realmData, GlobalServices globalServices)
{
var builder = new HostBuilder();
builder.ConfigureServices(c =>
{
c.AddSingleton(dbContextFactory);
c.AddSingleton(globalServices);
c.AddSingleton(new AsyncMonitor<Realm>(realmData));
c.AddSingleton(new AsyncMonitor<ApplicationDbContext>(dbContextFactory.CreateDbContext()));
c.AddSingleton(new PersistentObservableCollection<ForumBoard>(dbContextFactory, c => c.ForumBoards));
c.AddSingleton(new ConcurrentFactoryDictionary<Guid, ActiveBoard>(k => new ActiveBoard(dbContextFactory, k)));
c.AddSingleton(new ConcurrentFactoryDictionary<Guid, ActiveThread>(k => new ActiveThread(dbContextFactory, k)));
c.AddSingleton(new ConcurrentFactoryDictionary<Guid, ActiveBoard>(k => new ActiveBoard(dbContextFactory, k, c.BuildServiceProvider())));
c.AddSingleton(new ConcurrentFactoryDictionary<Guid, ActiveThread>(k => new ActiveThread(dbContextFactory, k, c.BuildServiceProvider())));
});
realmServiceHost = builder.Build();
@ -90,4 +92,6 @@ namespace PostLab.Data.Services.Realms
}
}
public record GlobalServices(IServiceProvider ServiceProvider);
}

View File

@ -7,10 +7,11 @@ namespace PostLab.Data.Services.Realms
{
private AsyncMonitor<Dictionary<Guid, ActiveRealm>> _activeRealms = new(new());
private IDbContextFactory<ApplicationDbContext> _dbContextFactory;
public RealmService(IDbContextFactory<ApplicationDbContext> dbContextFactory)
private IServiceProvider _serviceProvider;
public RealmService(IDbContextFactory<ApplicationDbContext> dbContextFactory, IServiceProvider serviceProvider)
{
_dbContextFactory = dbContextFactory;
_serviceProvider = serviceProvider;
}
public async Task<ActiveRealm?> GetActiveRealmAsync(Guid realmId)
@ -22,7 +23,7 @@ namespace PostLab.Data.Services.Realms
return realm;
}
var newRealm = await ActiveRealm.ActivateRealm(_dbContextFactory, realmId);
var newRealm = await ActiveRealm.ActivateRealm(_dbContextFactory, realmId, _serviceProvider);
if (newRealm == null)
{
return null;

View File

@ -0,0 +1,19 @@
<EditForm Model="@Model" OnSubmit="@HandleSubmitClicked">
<InputText id="content" @bind-Value="Model.Content" />
<button type="submit">Create</button>
</EditForm>
@code {
[Parameter]
public IComposerModel Model { get; init; }
[Parameter]
public EventCallback HandleSubmitClicked { get; init; }
public interface IComposerModel
{
public string Content { get; set; }
}
}

View File

@ -0,0 +1,53 @@
@page "/realm/{RealmId}/board/{BoardId}/thread/new";
@using PostLab.Data.Services;
@using PostLab.Data.Services.Forum;
@using PostLab.Data.Services.Realms;
@using PostLab.Pages.Controls;
@inject UserProfileService UserProfileService
@inject ProfileRealmService ProfileRealmService
@if (activeRealm == null && board == null)
{
<p><em>Loading...</em></p>
}
else if (board == null)
{
<p><em>Board not found :(</em></p>
}
else
{
<InputText id="title" @bind-Value="newThreadModel.Title" />
<Composer Model="@newThreadModel" HandleSubmitClicked="@OnSubmitClickedAsync" />
}
@code {
private ActiveRealm? activeRealm;
private ActiveBoard? board;
[Parameter]
public string RealmId { get; set; }
[Parameter]
public string BoardId { get; set; }
private ActiveBoard.NewThreadModel newThreadModel = new();
protected override async Task OnParametersSetAsync()
{
activeRealm = await ProfileRealmService.GetProfileRealmAsync(Guid.Parse(RealmId));
board = activeRealm.GetActiveBoard(Guid.Parse(BoardId));
}
private async Task OnSubmitClickedAsync()
{
if (board == null)
{
throw new InvalidOperationException("Board is null, cannot create thread");
}
await board.NewThreadAsync(newThreadModel);
}
}