feat: add core persistence model
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
2176
Tiku.Infrastructure/Persistence/Migrations/20260725205004_InitialCore.Designer.cs
generated
Normal file
2176
Tiku.Infrastructure/Persistence/Migrations/20260725205004_InitialCore.Designer.cs
generated
Normal file
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
73
Tiku.Infrastructure/Persistence/ModelBuilderExtensions.cs
Normal file
73
Tiku.Infrastructure/Persistence/ModelBuilderExtensions.cs
Normal file
@@ -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;
|
||||
72
Tiku.Infrastructure/Persistence/TikuDbContext.cs
Normal file
72
Tiku.Infrastructure/Persistence/TikuDbContext.cs
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user