feat: add core persistence model

This commit is contained in:
xiong
2026-07-26 04:52:56 +08:00
parent f5109de879
commit 5dfb68d8cc
33 changed files with 6989 additions and 31 deletions
+9
View File
@@ -11,9 +11,18 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageVersion> </PackageVersion>
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" /> <PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" /> <PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" /> <PackageVersion Include="Microsoft.OpenApi" Version="2.11.0" />
<PackageVersion Include="Npgsql" Version="10.0.3" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" /> <PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4"> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4">
+1 -1
View File
@@ -21,7 +21,7 @@ var summaries = new[]
app.MapGet("/weatherforecast", () => app.MapGet("/weatherforecast", () =>
{ {
var forecast = Enumerable.Range(1, 5).Select(index => var forecast = Enumerable.Range(1, 5).Select(index =>
new WeatherForecast new WeatherForecast
( (
DateOnly.FromDateTime(DateTime.Now.AddDays(index)), DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
-6
View File
@@ -1,6 +0,0 @@
namespace Tiku.Application;
public class Class1
{
}
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Tiku.Infrastructure.Persistence;
namespace Tiku.DbMigrator;
public sealed class DesignTimeTikuDbContextFactory : IDesignTimeDbContextFactory<TikuDbContext>
{
public TikuDbContext CreateDbContext(string[] args)
{
var connectionString =
Environment.GetEnvironmentVariable("DATABASE_URL") ??
"Host=localhost;Database=tiku;Username=postgres";
var options = new DbContextOptionsBuilder<TikuDbContext>()
.UseNpgsql(connectionString, npgsql =>
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName))
.Options;
return new TikuDbContext(options);
}
}
+20 -2
View File
@@ -1,2 +1,20 @@
// See https://aka.ms/new-console-template for more information using Microsoft.EntityFrameworkCore;
Console.WriteLine("Hello, World!"); using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Persistence;
var builder = Host.CreateApplicationBuilder(args);
var connectionString =
builder.Configuration.GetConnectionString("Database") ??
Environment.GetEnvironmentVariable("DATABASE_URL") ??
throw new InvalidOperationException(
"Database connection is required. Configure ConnectionStrings:Database or DATABASE_URL.");
builder.Services.AddInfrastructure(connectionString);
using var host = builder.Build();
await using var scope = host.Services.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<TikuDbContext>();
await dbContext.Database.MigrateAsync();
+8
View File
@@ -4,6 +4,14 @@
<ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj" /> <ProjectReference Include="..\Tiku.Infrastructure\Tiku.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<IncludeAssets>all</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
+126
View File
@@ -0,0 +1,126 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Catalog;
public sealed class Region : AuditableTenantEntity
{
public string? LegacyId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Code { get; set; }
public string? ShortName { get; set; }
public string? FullName { get; set; }
public string? Icon { get; set; }
public string? Pinyin { get; set; }
public int SortOrder { get; set; }
public bool IsHot { get; set; }
public bool IsActive { get; set; } = true;
public JsonElement Config { get; set; } = JsonDefaults.Object();
}
public sealed class RegionModule : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public string? LegacyId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Type { get; set; }
public string? Icon { get; set; }
public string? Color { get; set; }
public string? TextColor { get; set; }
public string? Description { get; set; }
public string? Route { get; set; }
public int SortOrder { get; set; }
public bool IsPrimarySchoolModule { get; set; }
public bool IsActive { get; set; } = true;
}
public sealed class ModuleNode : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public Guid? ModuleId { get; set; }
public Guid? ParentId { get; set; }
public string? LegacyId { get; set; }
public string? LegacyParentId { get; set; }
public string? LegacyModuleId { get; set; }
public ModuleNodeType Type { get; set; } = ModuleNodeType.Custom;
public string Name { get; set; } = string.Empty;
public string? Path { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public enum ModuleNodeType
{
Category,
Subject,
Chapter,
Paper,
School,
Major,
Custom
}
public sealed class School : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public Guid? ModuleId { get; set; }
public string? LegacyId { get; set; }
public string Name { get; set; } = string.Empty;
public string? ProfessionalExamDate { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class Major : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public Guid? SchoolId { get; set; }
public string? LegacyId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string? StudyTips { get; set; }
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
public sealed class Subject : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public Guid? ModuleId { get; set; }
public Guid? SchoolId { get; set; }
public Guid? MajorId { get; set; }
public Guid? NodeId { get; set; }
public string? LegacyId { get; set; }
public string Name { get; set; } = string.Empty;
public SubjectType? Type { get; set; }
public JsonElement MajorLegacyIds { get; set; } = JsonDefaults.Array();
public string? Icon { get; set; }
public string? Description { get; set; }
public JsonElement Stats { get; set; } = JsonDefaults.Object();
public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
}
public enum SubjectType
{
Cultural,
Professional
}
public sealed class Category : AuditableTenantEntity
{
public Guid? SubjectId { get; set; }
public Guid? NodeId { get; set; }
public string? LegacyId { get; set; }
public string Name { get; set; } = string.Empty;
public CategoryType? CategoryType { get; set; }
public int SortOrder { get; set; }
public int? SvipQuestionLimit { get; set; }
public bool IsActive { get; set; } = true;
}
public enum CategoryType
{
Chapter,
Paper
}
-6
View File
@@ -1,6 +0,0 @@
namespace Tiku.Domain;
public class Class1
{
}
+46
View File
@@ -0,0 +1,46 @@
using System.Text.Json;
namespace Tiku.Domain.Common;
public abstract class Entity
{
public Guid Id { get; set; } = Guid.NewGuid();
}
public interface IHasTimestamps
{
DateTimeOffset CreatedAt { get; set; }
DateTimeOffset UpdatedAt { get; set; }
}
public abstract class AuditableEntity : Entity, IHasTimestamps
{
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public abstract class TenantEntity : Entity
{
public Guid TenantId { get; set; }
}
public abstract class AuditableTenantEntity : TenantEntity, IHasTimestamps
{
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public static class JsonDefaults
{
public static JsonElement Object()
{
using var document = JsonDocument.Parse("{}");
return document.RootElement.Clone();
}
public static JsonElement Array()
{
using var document = JsonDocument.Parse("[]");
return document.RootElement.Clone();
}
}
+20
View File
@@ -0,0 +1,20 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Identity;
public sealed class StudentProfile : AuditableTenantEntity
{
public Guid UserId { get; set; }
public string? LegacyUserId { get; set; }
public Guid? RegionId { get; set; }
public Guid? SelectedSchoolId { get; set; }
public Guid? SelectedMajorId { get; set; }
public int QuestionsAnsweredToday { get; set; }
public int MasteredWordsCount { get; set; }
public DateOnly? LastCheckInDate { get; set; }
public JsonElement Stats { get; set; } = JsonDefaults.Object();
public JsonElement Progress { get; set; } = JsonDefaults.Object();
public JsonElement ModuleSelections { get; set; } = JsonDefaults.Object();
public JsonElement RecentActivities { get; set; } = JsonDefaults.Array();
}
+20
View File
@@ -0,0 +1,20 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Identity;
public sealed class User : AuditableEntity
{
public string? LegacyId { get; set; }
public string? Username { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? Name { get; set; }
public string? AvatarUrl { get; set; }
public string PrimaryRole { get; set; } = "student";
public int Score { get; set; }
public DateTimeOffset? LastSeenAt { get; set; }
public string? LegacyPasswordHash { get; set; }
public bool PasswordMigrationRequired { get; set; }
public JsonElement RawProfile { get; set; } = JsonDefaults.Object();
}
+16
View File
@@ -0,0 +1,16 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Identity;
public sealed class UserIdentity : AuditableEntity
{
public Guid UserId { get; set; }
public string Provider { get; set; } = string.Empty;
public string ProviderSubject { get; set; } = string.Empty;
public string? UnionId { get; set; }
public string? OpenId { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public JsonElement SecretPayload { get; set; } = JsonDefaults.Object();
}
+64
View File
@@ -0,0 +1,64 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Learning;
public sealed class PracticeSession : TenantEntity
{
public Guid UserId { get; set; }
public string Mode { get; set; } = "chapter";
public string? TargetType { get; set; }
public Guid? TargetId { get; set; }
public DateTimeOffset StartedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? FinishedAt { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public sealed class AnswerRecord : TenantEntity
{
public Guid UserId { get; set; }
public Guid? QuestionId { get; set; }
public Guid? QuestionVersionId { get; set; }
public Guid? PracticeSessionId { get; set; }
public string? LegacyId { get; set; }
public string? LegacyQuestionId { get; set; }
public string? LegacyCategoryId { get; set; }
public JsonElement SelectedOptions { get; set; } = JsonDefaults.Array();
public string? AnswerText { get; set; }
public bool? IsCorrect { get; set; }
public DateTimeOffset AnsweredAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class FavoriteQuestion
{
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
public Guid QuestionId { get; set; }
public string Source { get; set; } = "imported";
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class WrongQuestion
{
public Guid TenantId { get; set; }
public Guid UserId { get; set; }
public Guid QuestionId { get; set; }
public int WrongCount { get; set; } = 1;
public DateTimeOffset LastWrongAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? ResolvedAt { get; set; }
}
public sealed class RecentPractice : AuditableTenantEntity
{
public Guid? UserId { get; set; }
public string? LegacyId { get; set; }
public string? PracticeType { get; set; }
public string? TargetLegacyId { get; set; }
public string? TargetName { get; set; }
public int Progress { get; set; }
public string? Color { get; set; }
public DateTimeOffset? LastAccessAt { get; set; }
public DateTimeOffset? LastPracticeAt { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
@@ -0,0 +1,71 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.QuestionBanks;
public sealed class QuestionBank : AuditableTenantEntity
{
public Guid? RegionId { get; set; }
public string Name { get; set; } = string.Empty;
public QuestionBankScope SourceScope { get; set; } = QuestionBankScope.Tenant;
public QuestionBankStatus Status { get; set; } = QuestionBankStatus.Active;
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public enum QuestionBankScope
{
Platform,
Tenant
}
public enum QuestionBankStatus
{
Active,
Archived
}
public sealed class Question : AuditableTenantEntity
{
public Guid? QuestionBankId { get; set; }
public Guid? SubjectId { get; set; }
public Guid? CategoryId { get; set; }
public Guid? NodeId { get; set; }
public string? LegacyId { get; set; }
public string? LegacySubjectId { get; set; }
public string? LegacyCategoryId { get; set; }
public string? LegacyNodeId { get; set; }
public string Type { get; set; } = "choice";
public string? TypeLabel { get; set; }
public int? Difficulty { get; set; }
public JsonElement Tags { get; set; } = JsonDefaults.Array();
public string? MediaUrl { get; set; }
public bool HasVideoExplanation { get; set; }
public QuestionStatus Status { get; set; } = QuestionStatus.Published;
public Guid? CurrentVersionId { get; set; }
}
public enum QuestionStatus
{
Draft,
Published,
Archived
}
public sealed class QuestionVersion : Entity
{
public Guid TenantId { get; set; }
public Guid QuestionId { get; set; }
public int VersionNo { get; set; } = 1;
public string? Content { get; set; }
public JsonElement Options { get; set; } = JsonDefaults.Array();
public int? CorrectOptionIndex { get; set; }
public JsonElement CorrectOptionIndices { get; set; } = JsonDefaults.Array();
public string? AnswerText { get; set; }
public string? Explanation { get; set; }
public JsonElement SubQuestions { get; set; } = JsonDefaults.Array();
public string? CodeLang { get; set; }
public string? CodeTemplate { get; set; }
public string? SourceHash { get; set; }
public Guid? CreatedBy { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+40
View File
@@ -0,0 +1,40 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Tenancy;
public sealed class Tenant : AuditableEntity
{
public string Slug { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string? LegalName { get; set; }
public TenantStatus Status { get; set; } = TenantStatus.Active;
public TenantMode Mode { get; set; } = TenantMode.Saas;
public BillingStatus BillingStatus { get; set; } = BillingStatus.Trial;
public Guid? OwnerUserId { get; set; }
public string? LegacyId { get; set; }
public JsonElement Metadata { get; set; } = JsonDefaults.Object();
}
public enum TenantStatus
{
Draft,
Active,
Suspended,
Archived
}
public enum TenantMode
{
PlatformOwned,
Saas,
Dedicated
}
public enum BillingStatus
{
Trial,
Active,
PastDue,
Cancelled
}
+32
View File
@@ -0,0 +1,32 @@
using System.Text.Json;
using Tiku.Domain.Common;
namespace Tiku.Domain.Tenancy;
public sealed class TenantMembership : AuditableTenantEntity
{
public Guid UserId { get; set; }
public TenantRole Role { get; set; } = TenantRole.Student;
public MembershipStatus Status { get; set; } = MembershipStatus.Active;
public JsonElement Permissions { get; set; } = JsonDefaults.Object();
public string? LegacyRole { get; set; }
}
public enum TenantRole
{
PlatformAdmin,
TenantOwner,
TenantAdmin,
TenantOperator,
Teacher,
Sales,
Agent,
Student
}
public enum MembershipStatus
{
Active,
Invited,
Disabled
}
-6
View File
@@ -1,6 +0,0 @@
namespace Tiku.Infrastructure;
public class Class1
{
}
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Tiku.Infrastructure.Persistence;
namespace Tiku.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
string connectionString)
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString));
services.AddDbContextPool<TikuDbContext>((serviceProvider, options) =>
{
var dataSource = serviceProvider.GetRequiredService<NpgsqlDataSource>();
options.UseNpgsql(dataSource, npgsql =>
npgsql.MigrationsAssembly(typeof(TikuDbContext).Assembly.FullName));
});
return services;
}
}
@@ -0,0 +1,187 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class RegionConfiguration : IEntityTypeConfiguration<Region>
{
public void Configure(EntityTypeBuilder<Region> builder)
{
builder.ConfigureTenantEntity("regions");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Code).HasMaxLength(50);
builder.Property(entity => entity.ShortName).HasMaxLength(100);
builder.Property(entity => entity.FullName).HasMaxLength(300);
builder.Property(entity => entity.Icon).HasMaxLength(2048);
builder.Property(entity => entity.Pinyin).HasMaxLength(255);
builder.Property(entity => entity.Config).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
}
}
internal sealed class RegionModuleConfiguration : IEntityTypeConfiguration<RegionModule>
{
public void Configure(EntityTypeBuilder<RegionModule> builder)
{
builder.ConfigureTenantEntity("region_modules");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.Type).HasMaxLength(50);
builder.Property(entity => entity.Icon).HasMaxLength(2048);
builder.Property(entity => entity.Color).HasMaxLength(50);
builder.Property(entity => entity.TextColor).HasMaxLength(50);
builder.Property(entity => entity.Route).HasMaxLength(500);
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<Region>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class ModuleNodeConfiguration : IEntityTypeConfiguration<ModuleNode>
{
public void Configure(EntityTypeBuilder<ModuleNode> builder)
{
builder.ConfigureTenantEntity("module_nodes");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.LegacyParentId).HasMaxLength(64);
builder.Property(entity => entity.LegacyModuleId).HasMaxLength(64);
builder.Property(entity => entity.Type).HasSnakeCaseEnum();
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.Path).HasMaxLength(1000);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<Region>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<RegionModule>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ModuleId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ModuleNode>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ParentId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class SchoolConfiguration : IEntityTypeConfiguration<School>
{
public void Configure(EntityTypeBuilder<School> builder)
{
builder.ConfigureTenantEntity("schools");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.ProfessionalExamDate).HasMaxLength(255);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<Region>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<RegionModule>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ModuleId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class MajorConfiguration : IEntityTypeConfiguration<Major>
{
public void Configure(EntityTypeBuilder<Major> builder)
{
builder.ConfigureTenantEntity("majors");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<Region>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<School>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.SchoolId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class SubjectConfiguration : IEntityTypeConfiguration<Subject>
{
public void Configure(EntityTypeBuilder<Subject> builder)
{
builder.ConfigureTenantEntity("subjects");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.Type).HasNullableSnakeCaseEnum();
builder.Property(entity => entity.MajorLegacyIds).IsJson("[]");
builder.Property(entity => entity.Icon).HasMaxLength(2048);
builder.Property(entity => entity.Stats).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<Region>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<RegionModule>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.ModuleId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<School>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.SchoolId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Major>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.MajorId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ModuleNode>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
public void Configure(EntityTypeBuilder<Category> builder)
{
builder.ConfigureTenantEntity("categories");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.CategoryType).HasNullableSnakeCaseEnum();
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<Subject>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.SubjectId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ModuleNode>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,75 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Common;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal static class ConfigurationSupport
{
public static void ConfigureEntity<TEntity>(
this EntityTypeBuilder<TEntity> builder,
string tableName)
where TEntity : Entity
{
builder.ToTable(tableName);
builder.HasKey(entity => entity.Id);
builder.Property(entity => entity.Id).HasDefaultValueSql("gen_random_uuid()");
}
public static void ConfigureTenantEntity<TEntity>(
this EntityTypeBuilder<TEntity> builder,
string tableName)
where TEntity : TenantEntity
{
builder.ConfigureEntity(tableName);
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.HasOne<Tenant>()
.WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
}
public static void ConfigureTimestamps<TEntity>(this EntityTypeBuilder<TEntity> builder)
where TEntity : class, IHasTimestamps
{
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.Property(entity => entity.UpdatedAt).HasDefaultValueSql("now()");
}
public static PropertyBuilder<JsonElement> IsJson(
this PropertyBuilder<JsonElement> property,
string defaultJson)
{
return property
.HasColumnType("jsonb")
.HasDefaultValueSql($"'{defaultJson}'::jsonb");
}
public static PropertyBuilder<TEnum> HasSnakeCaseEnum<TEnum>(
this PropertyBuilder<TEnum> property,
int maxLength = 32)
where TEnum : struct, Enum
{
return property
.HasConversion(new SnakeCaseEnumConverter<TEnum>())
.HasMaxLength(maxLength);
}
public static PropertyBuilder<TEnum?> HasNullableSnakeCaseEnum<TEnum>(
this PropertyBuilder<TEnum?> property,
int maxLength = 32)
where TEnum : struct, Enum
{
return property
.HasConversion(
value => value.HasValue
? ModelBuilderExtensions.ToSnakeCase(value.Value.ToString())
: null,
value => string.IsNullOrWhiteSpace(value)
? null
: Enum.Parse<TEnum>(value.Replace("_", string.Empty), true))
.HasMaxLength(maxLength);
}
}
@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
using Tiku.Domain.Identity;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.ConfigureEntity("users");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Username).HasMaxLength(100);
builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320);
builder.Property(entity => entity.Phone).HasMaxLength(32);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.AvatarUrl).HasMaxLength(2048);
builder.Property(entity => entity.PrimaryRole).HasMaxLength(50);
builder.Property(entity => entity.LegacyPasswordHash).HasMaxLength(512);
builder.Property(entity => entity.RawProfile).IsJson("{}");
builder.HasIndex(entity => entity.LegacyId).IsUnique();
builder.HasIndex(entity => entity.Username).IsUnique();
builder.HasIndex(entity => entity.Email).IsUnique();
builder.HasIndex(entity => entity.Phone).IsUnique();
}
}
internal sealed class UserIdentityConfiguration : IEntityTypeConfiguration<UserIdentity>
{
public void Configure(EntityTypeBuilder<UserIdentity> builder)
{
builder.ConfigureEntity("user_identities");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Provider).HasMaxLength(50);
builder.Property(entity => entity.ProviderSubject).HasMaxLength(255);
builder.Property(entity => entity.UnionId).HasMaxLength(255);
builder.Property(entity => entity.OpenId).HasMaxLength(255);
builder.Property(entity => entity.Phone).HasMaxLength(32);
builder.Property(entity => entity.Email).HasColumnType("citext").HasMaxLength(320);
builder.Property(entity => entity.SecretPayload).IsJson("{}");
builder.HasIndex(entity => new { entity.Provider, entity.ProviderSubject }).IsUnique();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class StudentProfileConfiguration : IEntityTypeConfiguration<StudentProfile>
{
public void Configure(EntityTypeBuilder<StudentProfile> builder)
{
builder.ConfigureTenantEntity("student_profiles");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyUserId).HasMaxLength(64);
builder.Property(entity => entity.Stats).IsJson("{}");
builder.Property(entity => entity.Progress).IsJson("{}");
builder.Property(entity => entity.ModuleSelections).IsJson("{}");
builder.Property(entity => entity.RecentActivities).IsJson("[]");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId }).IsUnique();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Region>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<School>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.SelectedSchoolId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Major>()
.WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.SelectedMajorId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
@@ -0,0 +1,159 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class PracticeSessionConfiguration : IEntityTypeConfiguration<PracticeSession>
{
public void Configure(EntityTypeBuilder<PracticeSession> builder)
{
builder.ConfigureTenantEntity("practice_sessions");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.UserId, entity.Id });
builder.Property(entity => entity.Mode).HasMaxLength(50);
builder.Property(entity => entity.TargetType).HasMaxLength(50);
builder.Property(entity => entity.StartedAt).HasDefaultValueSql("now()");
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.StartedAt });
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class AnswerRecordConfiguration : IEntityTypeConfiguration<AnswerRecord>
{
public void Configure(EntityTypeBuilder<AnswerRecord> builder)
{
builder.ConfigureTenantEntity("answer_records");
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.LegacyQuestionId).HasMaxLength(64);
builder.Property(entity => entity.LegacyCategoryId).HasMaxLength(64);
builder.Property(entity => entity.SelectedOptions).IsJson("[]");
builder.Property(entity => entity.AnsweredAt).HasDefaultValueSql("now()");
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.QuestionId });
builder.HasIndex(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.QuestionVersionId
});
builder.HasIndex(entity => new
{
entity.TenantId,
entity.UserId,
entity.PracticeSessionId
});
builder.ToTable(table => table.HasCheckConstraint(
"ck_answer_records_version_requires_question",
"question_version_id is null or question_id is not null"));
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionVersion>().WithMany()
.HasForeignKey(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.QuestionVersionId
})
.HasPrincipalKey(entity => new
{
entity.TenantId,
entity.QuestionId,
entity.Id
})
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<PracticeSession>().WithMany()
.HasForeignKey(entity => new
{
entity.TenantId,
entity.UserId,
entity.PracticeSessionId
})
.HasPrincipalKey(entity => new
{
entity.TenantId,
entity.UserId,
entity.Id
})
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class FavoriteQuestionConfiguration : IEntityTypeConfiguration<FavoriteQuestion>
{
public void Configure(EntityTypeBuilder<FavoriteQuestion> builder)
{
builder.ToTable("favorite_questions");
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionId });
builder.Property(entity => entity.Source).HasMaxLength(50);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasOne<Tenant>().WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class WrongQuestionConfiguration : IEntityTypeConfiguration<WrongQuestion>
{
public void Configure(EntityTypeBuilder<WrongQuestion> builder)
{
builder.ToTable("wrong_questions");
builder.HasKey(entity => new { entity.TenantId, entity.UserId, entity.QuestionId });
builder.Property(entity => entity.WrongCount).HasDefaultValue(1);
builder.Property(entity => entity.LastWrongAt).HasDefaultValueSql("now()");
builder.HasOne<Tenant>().WithMany()
.HasForeignKey(entity => entity.TenantId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
}
}
internal sealed class RecentPracticeConfiguration : IEntityTypeConfiguration<RecentPractice>
{
public void Configure(EntityTypeBuilder<RecentPractice> builder)
{
builder.ConfigureTenantEntity("recent_practices");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.PracticeType).HasMaxLength(50);
builder.Property(entity => entity.TargetLegacyId).HasMaxLength(64);
builder.Property(entity => entity.TargetName).HasMaxLength(300);
builder.Property(entity => entity.Color).HasMaxLength(50);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.LastAccessAt });
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
@@ -0,0 +1,92 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Catalog;
using Tiku.Domain.Identity;
using Tiku.Domain.QuestionBanks;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class QuestionBankConfiguration : IEntityTypeConfiguration<QuestionBank>
{
public void Configure(EntityTypeBuilder<QuestionBank> builder)
{
builder.ConfigureTenantEntity("question_banks");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Name).HasMaxLength(300);
builder.Property(entity => entity.SourceScope).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasOne<Region>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class QuestionConfiguration : IEntityTypeConfiguration<Question>
{
public void Configure(EntityTypeBuilder<Question> builder)
{
builder.ConfigureTenantEntity("questions");
builder.ConfigureTimestamps();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.LegacySubjectId).HasMaxLength(64);
builder.Property(entity => entity.LegacyCategoryId).HasMaxLength(64);
builder.Property(entity => entity.LegacyNodeId).HasMaxLength(64);
builder.Property(entity => entity.Type).HasMaxLength(50);
builder.Property(entity => entity.TypeLabel).HasMaxLength(100);
builder.Property(entity => entity.Tags).IsJson("[]");
builder.Property(entity => entity.MediaUrl).HasMaxLength(2048);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
builder.HasOne<QuestionBank>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionBankId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Subject>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.SubjectId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<Category>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.CategoryId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<ModuleNode>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.NodeId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
builder.HasOne<QuestionVersion>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.Id, entity.CurrentVersionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.QuestionId, entity.Id })
.OnDelete(DeleteBehavior.Restrict);
}
}
internal sealed class QuestionVersionConfiguration : IEntityTypeConfiguration<QuestionVersion>
{
public void Configure(EntityTypeBuilder<QuestionVersion> builder)
{
builder.ConfigureEntity("question_versions");
builder.HasAlternateKey(entity => new { entity.TenantId, entity.Id });
builder.HasAlternateKey(entity => new { entity.TenantId, entity.QuestionId, entity.Id });
builder.Property(entity => entity.Options).IsJson("[]");
builder.Property(entity => entity.CorrectOptionIndices).IsJson("[]");
builder.Property(entity => entity.SubQuestions).IsJson("[]");
builder.Property(entity => entity.CodeLang).HasMaxLength(50);
builder.Property(entity => entity.SourceHash).HasMaxLength(128);
builder.Property(entity => entity.CreatedAt).HasDefaultValueSql("now()");
builder.HasIndex(entity => new { entity.QuestionId, entity.VersionNo }).IsUnique();
builder.HasOne<Question>().WithMany()
.HasForeignKey(entity => new { entity.TenantId, entity.QuestionId })
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<User>().WithMany()
.HasForeignKey(entity => entity.CreatedBy)
.OnDelete(DeleteBehavior.SetNull);
}
}
@@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Tiku.Domain.Identity;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence.Configurations;
internal sealed class TenantConfiguration : IEntityTypeConfiguration<Tenant>
{
public void Configure(EntityTypeBuilder<Tenant> builder)
{
builder.ConfigureEntity("tenants");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Slug).HasColumnType("citext").HasMaxLength(100);
builder.Property(entity => entity.Name).HasMaxLength(200);
builder.Property(entity => entity.LegalName).HasMaxLength(300);
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Mode).HasSnakeCaseEnum();
builder.Property(entity => entity.BillingStatus).HasSnakeCaseEnum();
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
builder.Property(entity => entity.Metadata).IsJson("{}");
builder.HasIndex(entity => entity.Slug).IsUnique();
builder.HasIndex(entity => entity.LegacyId).IsUnique();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(entity => entity.OwnerUserId)
.OnDelete(DeleteBehavior.SetNull);
}
}
internal sealed class TenantMembershipConfiguration : IEntityTypeConfiguration<TenantMembership>
{
public void Configure(EntityTypeBuilder<TenantMembership> builder)
{
builder.ConfigureTenantEntity("tenant_memberships");
builder.ConfigureTimestamps();
builder.Property(entity => entity.Role).HasSnakeCaseEnum();
builder.Property(entity => entity.Status).HasSnakeCaseEnum();
builder.Property(entity => entity.Permissions).IsJson("{}");
builder.Property(entity => entity.LegacyRole).HasMaxLength(50);
builder.HasIndex(entity => new { entity.TenantId, entity.UserId, entity.Role }).IsUnique();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(entity => entity.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
using System.Text;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace Tiku.Infrastructure.Persistence;
internal static class ModelBuilderExtensions
{
public static void UseSnakeCaseIdentifiers(this ModelBuilder modelBuilder)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
foreach (var property in entityType.GetProperties())
{
property.SetColumnName(ToSnakeCase(property.Name));
}
foreach (var key in entityType.GetKeys())
{
if (key.GetName() is { } keyName)
{
key.SetName(ToSnakeCase(keyName));
}
}
foreach (var foreignKey in entityType.GetForeignKeys())
{
if (foreignKey.GetConstraintName() is { } constraintName)
{
foreignKey.SetConstraintName(ToSnakeCase(constraintName));
}
}
foreach (var index in entityType.GetIndexes())
{
if (index.GetDatabaseName() is { } indexName)
{
index.SetDatabaseName(ToSnakeCase(indexName));
}
}
}
}
public static string ToSnakeCase(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
var builder = new StringBuilder(value.Length + 8);
for (var index = 0; index < value.Length; index++)
{
var character = value[index];
if (char.IsUpper(character) && index > 0 &&
(char.IsLower(value[index - 1]) ||
index + 1 < value.Length && char.IsLower(value[index + 1])))
{
builder.Append('_');
}
builder.Append(char.ToLowerInvariant(character));
}
return builder.ToString();
}
}
internal sealed class SnakeCaseEnumConverter<TEnum>() : ValueConverter<TEnum, string>(
value => ModelBuilderExtensions.ToSnakeCase(value.ToString()),
value => Enum.Parse<TEnum>(value.Replace("_", string.Empty), true))
where TEnum : struct, Enum;
@@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore;
using Tiku.Domain.Catalog;
using Tiku.Domain.Common;
using Tiku.Domain.Identity;
using Tiku.Domain.Learning;
using Tiku.Domain.QuestionBanks;
using Tiku.Domain.Tenancy;
namespace Tiku.Infrastructure.Persistence;
public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbContext(options)
{
public DbSet<Tenant> Tenants => Set<Tenant>();
public DbSet<User> Users => Set<User>();
public DbSet<UserIdentity> UserIdentities => Set<UserIdentity>();
public DbSet<TenantMembership> TenantMemberships => Set<TenantMembership>();
public DbSet<StudentProfile> StudentProfiles => Set<StudentProfile>();
public DbSet<Region> Regions => Set<Region>();
public DbSet<RegionModule> RegionModules => Set<RegionModule>();
public DbSet<ModuleNode> ModuleNodes => Set<ModuleNode>();
public DbSet<School> Schools => Set<School>();
public DbSet<Major> Majors => Set<Major>();
public DbSet<Subject> Subjects => Set<Subject>();
public DbSet<Category> Categories => Set<Category>();
public DbSet<QuestionBank> QuestionBanks => Set<QuestionBank>();
public DbSet<Question> Questions => Set<Question>();
public DbSet<QuestionVersion> QuestionVersions => Set<QuestionVersion>();
public DbSet<PracticeSession> PracticeSessions => Set<PracticeSession>();
public DbSet<AnswerRecord> AnswerRecords => Set<AnswerRecord>();
public DbSet<FavoriteQuestion> FavoriteQuestions => Set<FavoriteQuestion>();
public DbSet<WrongQuestion> WrongQuestions => Set<WrongQuestion>();
public DbSet<RecentPractice> RecentPractices => Set<RecentPractice>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasPostgresExtension("citext");
modelBuilder.ApplyConfigurationsFromAssembly(typeof(TikuDbContext).Assembly);
modelBuilder.UseSnakeCaseIdentifiers();
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
UpdateTimestamps();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(
bool acceptAllChangesOnSuccess,
CancellationToken cancellationToken = default)
{
UpdateTimestamps();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void UpdateTimestamps()
{
var now = DateTimeOffset.UtcNow;
foreach (var entry in ChangeTracker.Entries<IHasTimestamps>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
}
if (entry.State is EntityState.Added or EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
}
}
}
@@ -5,6 +5,14 @@
<ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" /> <ProjectReference Include="..\Tiku.Domain\Tiku.Domain.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;
using Tiku.Domain.QuestionBanks;
using Tiku.Infrastructure.Persistence;
namespace Tiku.IntegrationTests;
public sealed class PersistenceModelTests
{
private static readonly DbContextOptions<TikuDbContext> Options =
new DbContextOptionsBuilder<TikuDbContext>()
.UseNpgsql("Host=localhost;Database=tiku_model_tests;Username=postgres")
.Options;
[Fact]
public void Core_model_contains_expected_tables()
{
using var context = new TikuDbContext(Options);
var tableNames = context.Model.GetEntityTypes()
.Select(entity => entity.GetTableName())
.ToHashSet(StringComparer.Ordinal);
Assert.Equal(20, tableNames.Count);
Assert.Contains("tenants", tableNames);
Assert.Contains("questions", tableNames);
Assert.Contains("question_versions", tableNames);
Assert.Contains("answer_records", tableNames);
}
[Theory]
[InlineData(nameof(Question.Tags), "'[]'::jsonb")]
public void Question_json_properties_are_mapped_to_jsonb(
string propertyName,
string expectedDefaultValueSql)
{
using var context = new TikuDbContext(Options);
var property = context.Model.FindEntityType(typeof(Question))!
.FindProperty(propertyName)!;
Assert.Equal("jsonb", property.GetColumnType());
Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql());
Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType);
}
[Fact]
public void Question_relations_include_tenant_in_foreign_keys()
{
using var context = new TikuDbContext(Options);
var foreignKeys = context.Model.FindEntityType(typeof(Question))!
.GetForeignKeys()
.Select(foreignKey => foreignKey.Properties
.Select(property => property.Name)
.ToArray())
.ToArray();
Assert.All(
foreignKeys.Where(properties => properties.Length > 1),
properties => Assert.Contains("TenantId", properties));
Assert.Contains(
foreignKeys,
properties => properties.SequenceEqual(
["TenantId", "Id", "CurrentVersionId"]));
}
}
-10
View File
@@ -1,10 +0,0 @@
namespace Tiku.IntegrationTests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.10",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}