178 lines
		
	
	
		
			6.1 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			178 lines
		
	
	
		
			6.1 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using System.Linq.Expressions;
 | |
| using System.Reflection;
 | |
| using DysonNetwork.Shared.Models;
 | |
| using Microsoft.EntityFrameworkCore;
 | |
| using Microsoft.EntityFrameworkCore.Design;
 | |
| using Microsoft.EntityFrameworkCore.Query;
 | |
| using NodaTime;
 | |
| using Quartz;
 | |
| 
 | |
| namespace DysonNetwork.Ring;
 | |
| 
 | |
| public class AppDatabase(
 | |
|     DbContextOptions<AppDatabase> options,
 | |
|     IConfiguration configuration
 | |
| ) : DbContext(options)
 | |
| {
 | |
|     public DbSet<Shared.Models.SnNotification> Notifications { get; set; } = null!;
 | |
|     public DbSet<SnNotificationPushSubscription> PushSubscriptions { get; set; } = null!;
 | |
| 
 | |
|     protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
 | |
|     {
 | |
|         optionsBuilder.UseNpgsql(
 | |
|             configuration.GetConnectionString("App"),
 | |
|             opt => opt
 | |
|                 .ConfigureDataSource(optSource => optSource.EnableDynamicJson())
 | |
|                 .UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)
 | |
|                 .UseNodaTime()
 | |
|         ).UseSnakeCaseNamingConvention();
 | |
| 
 | |
|         base.OnConfiguring(optionsBuilder);
 | |
|     }
 | |
| 
 | |
|     protected override void OnModelCreating(ModelBuilder modelBuilder)
 | |
|     {
 | |
|         base.OnModelCreating(modelBuilder);
 | |
| 
 | |
|         // Automatically apply soft-delete filter to all entities inheriting BaseModel
 | |
|         foreach (var entityType in modelBuilder.Model.GetEntityTypes())
 | |
|         {
 | |
|             if (!typeof(ModelBase).IsAssignableFrom(entityType.ClrType)) continue;
 | |
|             var method = typeof(AppDatabase)
 | |
|                 .GetMethod(nameof(SetSoftDeleteFilter),
 | |
|                     BindingFlags.NonPublic | BindingFlags.Static)!
 | |
|                 .MakeGenericMethod(entityType.ClrType);
 | |
| 
 | |
|             method.Invoke(null, [modelBuilder]);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private static void SetSoftDeleteFilter<TEntity>(ModelBuilder modelBuilder)
 | |
|         where TEntity : ModelBase
 | |
|     {
 | |
|         modelBuilder.Entity<TEntity>().HasQueryFilter(e => e.DeletedAt == null);
 | |
|     }
 | |
| 
 | |
|     public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
 | |
|     {
 | |
|         var now = SystemClock.Instance.GetCurrentInstant();
 | |
| 
 | |
|         foreach (var entry in ChangeTracker.Entries<ModelBase>())
 | |
|         {
 | |
|             switch (entry.State)
 | |
|             {
 | |
|                 case EntityState.Added:
 | |
|                     entry.Entity.CreatedAt = now;
 | |
|                     entry.Entity.UpdatedAt = now;
 | |
|                     break;
 | |
|                 case EntityState.Modified:
 | |
|                     entry.Entity.UpdatedAt = now;
 | |
|                     break;
 | |
|                 case EntityState.Deleted:
 | |
|                     entry.State = EntityState.Modified;
 | |
|                     entry.Entity.DeletedAt = now;
 | |
|                     break;
 | |
|                 case EntityState.Detached:
 | |
|                 case EntityState.Unchanged:
 | |
|                 default:
 | |
|                     break;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         return await base.SaveChangesAsync(cancellationToken);
 | |
|     }
 | |
| }
 | |
| 
 | |
| public class AppDatabaseRecyclingJob(AppDatabase db, ILogger<AppDatabaseRecyclingJob> logger) : IJob
 | |
| {
 | |
|     public async Task Execute(IJobExecutionContext context)
 | |
|     {
 | |
|         var now = SystemClock.Instance.GetCurrentInstant();
 | |
| 
 | |
|         logger.LogInformation("Deleting soft-deleted records...");
 | |
| 
 | |
|         var threshold = now - Duration.FromDays(7);
 | |
| 
 | |
|         var entityTypes = db.Model.GetEntityTypes()
 | |
|             .Where(t => typeof(ModelBase).IsAssignableFrom(t.ClrType) && t.ClrType != typeof(ModelBase))
 | |
|             .Select(t => t.ClrType);
 | |
| 
 | |
|         foreach (var entityType in entityTypes)
 | |
|         {
 | |
|             var set = (IQueryable)db.GetType().GetMethod(nameof(DbContext.Set), Type.EmptyTypes)!
 | |
|                 .MakeGenericMethod(entityType).Invoke(db, null)!;
 | |
|             var parameter = Expression.Parameter(entityType, "e");
 | |
|             var property = Expression.Property(parameter, nameof(ModelBase.DeletedAt));
 | |
|             var condition = Expression.LessThan(property, Expression.Constant(threshold, typeof(Instant?)));
 | |
|             var notNull = Expression.NotEqual(property, Expression.Constant(null, typeof(Instant?)));
 | |
|             var finalCondition = Expression.AndAlso(notNull, condition);
 | |
|             var lambda = Expression.Lambda(finalCondition, parameter);
 | |
| 
 | |
|             var queryable = set.Provider.CreateQuery(
 | |
|                 Expression.Call(
 | |
|                     typeof(Queryable),
 | |
|                     "Where",
 | |
|                     [entityType],
 | |
|                     set.Expression,
 | |
|                     Expression.Quote(lambda)
 | |
|                 )
 | |
|             );
 | |
| 
 | |
|             var toListAsync = typeof(EntityFrameworkQueryableExtensions)
 | |
|                 .GetMethod(nameof(EntityFrameworkQueryableExtensions.ToListAsync))!
 | |
|                 .MakeGenericMethod(entityType);
 | |
| 
 | |
|             var items = await (dynamic)toListAsync.Invoke(null, [queryable, CancellationToken.None])!;
 | |
|             db.RemoveRange(items);
 | |
|         }
 | |
| 
 | |
|         await db.SaveChangesAsync();
 | |
|     }
 | |
| }
 | |
| 
 | |
| public class AppDatabaseFactory : IDesignTimeDbContextFactory<AppDatabase>
 | |
| {
 | |
|     public AppDatabase CreateDbContext(string[] args)
 | |
|     {
 | |
|         var configuration = new ConfigurationBuilder()
 | |
|             .SetBasePath(Directory.GetCurrentDirectory())
 | |
|             .AddJsonFile("appsettings.json")
 | |
|             .Build();
 | |
| 
 | |
|         var optionsBuilder = new DbContextOptionsBuilder<AppDatabase>();
 | |
|         return new AppDatabase(optionsBuilder.Options, configuration);
 | |
|     }
 | |
| }
 | |
| 
 | |
| public static class OptionalQueryExtensions
 | |
| {
 | |
|     public static IQueryable<T> If<T>(
 | |
|         this IQueryable<T> source,
 | |
|         bool condition,
 | |
|         Func<IQueryable<T>, IQueryable<T>> transform
 | |
|     )
 | |
|     {
 | |
|         return condition ? transform(source) : source;
 | |
|     }
 | |
| 
 | |
|     public static IQueryable<T> If<T, TP>(
 | |
|         this IIncludableQueryable<T, TP> source,
 | |
|         bool condition,
 | |
|         Func<IIncludableQueryable<T, TP>, IQueryable<T>> transform
 | |
|     )
 | |
|         where T : class
 | |
|     {
 | |
|         return condition ? transform(source) : source;
 | |
|     }
 | |
| 
 | |
|     public static IQueryable<T> If<T, TP>(
 | |
|         this IIncludableQueryable<T, IEnumerable<TP>> source,
 | |
|         bool condition,
 | |
|         Func<IIncludableQueryable<T, IEnumerable<TP>>, IQueryable<T>> transform
 | |
|     )
 | |
|         where T : class
 | |
|     {
 | |
|         return condition ? transform(source) : source;
 | |
|     }
 | |
| }
 |