forked from xiongyuxing/tiku-backend.net
feat: add scoreline dynamic records
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Application.Catalog;
|
||||
@@ -17,6 +18,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
||||
{
|
||||
private const int DefaultLimit = 100;
|
||||
private const int MaxLimit = 1000;
|
||||
private static readonly Regex ScorelineFieldKeyRegex = new("^[A-Za-z][A-Za-z0-9_]{0,63}$", RegexOptions.Compiled);
|
||||
private static readonly HashSet<string> SupportedImportTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"questions",
|
||||
@@ -609,13 +611,151 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
||||
return new ContentManagementResult<Major>(item);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ScorelineField>> GetScorelineFieldsAsync(
|
||||
DirectContentActor actor,
|
||||
AdminLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ScorelineFields.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value || item.RegionId == null);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item => item.FieldKey.Contains(keyword) || item.FieldName.Contains(keyword));
|
||||
}
|
||||
|
||||
return new CatalogList<ScorelineField>(await query
|
||||
.OrderBy(item => item.SortOrder)
|
||||
.ThenBy(item => item.FieldName)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<ScorelineField>> UpsertScorelineFieldAsync(
|
||||
DirectContentActor actor,
|
||||
ScorelineFieldCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(command.FieldName);
|
||||
if (!ScorelineFieldKeyRegex.IsMatch(command.FieldKey.Trim()))
|
||||
{
|
||||
throw new ContentManagementException("Scoreline field key is invalid.", "scoreline_field_key_invalid");
|
||||
}
|
||||
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineFields, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new ScorelineField { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.RegionId = command.RegionId;
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
item.FieldKey = command.FieldKey.Trim();
|
||||
item.FieldName = command.FieldName.Trim();
|
||||
item.FieldType = Normalize(command.FieldType) ?? "text";
|
||||
item.Unit = Normalize(command.Unit);
|
||||
item.IsFilter = command.IsFilter ?? item.IsFilter;
|
||||
item.IsRequired = command.IsRequired ?? item.IsRequired;
|
||||
item.IsVisible = command.IsVisible ?? item.IsVisible;
|
||||
item.IsTrend = command.IsTrend ?? item.IsTrend;
|
||||
item.Options = JsonArrayOrDefault(command.Options);
|
||||
item.Placeholder = Normalize(command.Placeholder);
|
||||
item.Description = Normalize(command.Description);
|
||||
item.SortOrder = command.Order ?? item.SortOrder;
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.ScorelineFields.Add(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<ScorelineField>(item);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ScorelineRecord>> GetScorelineRecordsAsync(
|
||||
DirectContentActor actor,
|
||||
AdminLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ScorelineRecords.AsNoTracking().Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (filter.SchoolId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.SchoolId == filter.SchoolId.Value);
|
||||
}
|
||||
|
||||
if (filter.MajorId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.MajorId == filter.MajorId.Value);
|
||||
}
|
||||
|
||||
if (filter.Year.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.Year == filter.Year.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(item =>
|
||||
(item.SchoolName != null && item.SchoolName.Contains(keyword)) ||
|
||||
(item.MajorName != null && item.MajorName.Contains(keyword)));
|
||||
}
|
||||
|
||||
return new CatalogList<ScorelineRecord>(await query
|
||||
.OrderByDescending(item => item.Year)
|
||||
.ThenBy(item => item.SchoolName)
|
||||
.ThenBy(item => item.MajorName)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<ContentManagementResult<ScorelineRecord>> UpsertScorelineRecordAsync(
|
||||
DirectContentActor actor,
|
||||
ScorelineRecordCommand command,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (command.Year is < 1900 or > 3000)
|
||||
{
|
||||
throw new ContentManagementException("Scoreline record year is invalid.", "scoreline_year_invalid");
|
||||
}
|
||||
|
||||
await AssertReferenceAsync<Region>(actor.TenantId, command.RegionId, "region_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<School>(actor.TenantId, command.SchoolId, "school_not_found", cancellationToken);
|
||||
await AssertReferenceAsync<Major>(actor.TenantId, command.MajorId, "major_not_found", cancellationToken);
|
||||
var item = await ResolveByIdOrLegacyAsync(dbContext.ScorelineRecords, actor.TenantId, command.Id, command.LegacyId, cancellationToken);
|
||||
var isNew = item is null;
|
||||
item ??= new ScorelineRecord { Id = command.Id ?? Guid.NewGuid(), TenantId = actor.TenantId };
|
||||
item.RegionId = command.RegionId;
|
||||
item.SchoolId = command.SchoolId;
|
||||
item.MajorId = command.MajorId;
|
||||
item.LegacyId = Normalize(command.LegacyId);
|
||||
item.Year = command.Year;
|
||||
item.SchoolName = Normalize(command.SchoolName);
|
||||
item.MajorName = Normalize(command.MajorName);
|
||||
item.FieldValues = JsonObjectOrDefault(command.FieldValues);
|
||||
if (isNew)
|
||||
{
|
||||
dbContext.ScorelineRecords.Add(item);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
return new ContentManagementResult<ScorelineRecord>(item);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<int>> GetScorelineYearsAsync(
|
||||
DirectContentActor actor,
|
||||
AdminLimitFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ExamDates.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.ExamAt.HasValue);
|
||||
var query = dbContext.ScorelineRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId);
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(item => item.RegionId == filter.RegionId.Value);
|
||||
@@ -627,7 +767,7 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
||||
}
|
||||
|
||||
var years = await query
|
||||
.Select(item => item.ExamAt!.Value.Year)
|
||||
.Select(item => item.Year)
|
||||
.Distinct()
|
||||
.OrderByDescending(year => year)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
@@ -644,14 +784,17 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
||||
var items = new List<ScorelineTrendItem>();
|
||||
foreach (var year in years.Items)
|
||||
{
|
||||
var schoolCount = await dbContext.ExamDates.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.ExamAt.HasValue && item.ExamAt.Value.Year == year)
|
||||
var schoolCount = await dbContext.ScorelineRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Year == year)
|
||||
.Select(item => item.SchoolId)
|
||||
.Where(id => id.HasValue)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
var majorCount = await dbContext.Majors.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.IsActive)
|
||||
var majorCount = await dbContext.ScorelineRecords.AsNoTracking()
|
||||
.Where(item => item.TenantId == actor.TenantId && item.Year == year)
|
||||
.Select(item => item.MajorId)
|
||||
.Where(id => id.HasValue)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
items.Add(new ScorelineTrendItem(year, schoolCount, majorCount));
|
||||
}
|
||||
@@ -1072,14 +1215,17 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
||||
GetElement(payload, "metadata", JsonDefaults.Object())), cancellationToken);
|
||||
return ("handbook_entry", entry.Item.Id);
|
||||
case "scoreline":
|
||||
var school = await UpsertSchoolAsync(actor, new SchoolCommand(
|
||||
var scoreline = await UpsertScorelineRecordAsync(actor, new ScorelineRecordCommand(
|
||||
null,
|
||||
command.RegionId,
|
||||
GetGuid(payload, "schoolId"),
|
||||
GetGuid(payload, "majorId"),
|
||||
GetString(payload, "legacyId"),
|
||||
GetString(payload, "schoolName") ?? GetString(payload, "name") ?? "未命名学校",
|
||||
GetString(payload, "professionalExamDate"),
|
||||
payload), cancellationToken);
|
||||
return ("school", school.Item.Id);
|
||||
GetInt(payload, "year") ?? DateTimeOffset.UtcNow.Year,
|
||||
GetString(payload, "schoolName"),
|
||||
GetString(payload, "majorName"),
|
||||
GetElement(payload, "fieldValues", payload)), cancellationToken);
|
||||
return ("scoreline_record", scoreline.Item.Id);
|
||||
case "videos":
|
||||
var video = await UpsertVideoAsync(actor, new VideoExplanationCommand(
|
||||
null,
|
||||
@@ -1722,6 +1868,19 @@ public sealed class DirectContentService(TikuDbContext dbContext) : IDirectConte
|
||||
: null;
|
||||
}
|
||||
|
||||
private static Guid? GetGuid(JsonElement payload, string name)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind == JsonValueKind.String &&
|
||||
Guid.TryParse(value.GetString(), out var guid)
|
||||
? guid
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool? GetBool(JsonElement payload, string name)
|
||||
{
|
||||
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(name, out var value))
|
||||
|
||||
@@ -7,6 +7,7 @@ using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Content;
|
||||
using Tiku.Application.Learning;
|
||||
using Tiku.Application.QuestionBanks;
|
||||
using Tiku.Application.Scoreline;
|
||||
using Tiku.Application.Storage;
|
||||
using Tiku.Application.StudyContent;
|
||||
using Tiku.Infrastructure.Assets;
|
||||
@@ -16,6 +17,7 @@ using Tiku.Infrastructure.Content;
|
||||
using Tiku.Infrastructure.Learning;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Tiku.Infrastructure.QuestionBanks;
|
||||
using Tiku.Infrastructure.Scoreline;
|
||||
using Tiku.Infrastructure.Storage;
|
||||
using Tiku.Infrastructure.StudyContent;
|
||||
|
||||
@@ -47,6 +49,7 @@ public static class DependencyInjection
|
||||
services.AddScoped<IContentManagementService, ContentManagementService>();
|
||||
services.AddScoped<IDirectContentService, DirectContentService>();
|
||||
services.AddScoped<IQuestionBankQueryService, QuestionBankQueryService>();
|
||||
services.AddScoped<IScorelineQueryService, ScorelineQueryService>();
|
||||
services.AddScoped<IStudyContentQueryService, StudyContentQueryService>();
|
||||
services.AddScoped<IAssetQueryService, AssetQueryService>();
|
||||
services.AddScoped<IAssetAccessService, AssetAccessService>();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Tiku.Domain.Catalog;
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Configurations;
|
||||
|
||||
internal sealed class ScorelineFieldConfiguration : IEntityTypeConfiguration<ScorelineField>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ScorelineField> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("scoreline_fields");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||
builder.Property(entity => entity.FieldKey).HasMaxLength(64);
|
||||
builder.Property(entity => entity.FieldName).HasMaxLength(200);
|
||||
builder.Property(entity => entity.FieldType).HasMaxLength(32);
|
||||
builder.Property(entity => entity.Unit).HasMaxLength(32);
|
||||
builder.Property(entity => entity.Options).IsJson("[]");
|
||||
builder.Property(entity => entity.Placeholder).HasMaxLength(200);
|
||||
builder.Property(entity => entity.Description).HasMaxLength(1000);
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.FieldKey }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.IsFilter, entity.SortOrder });
|
||||
|
||||
builder.HasOne<Region>()
|
||||
.WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.RegionId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ScorelineRecordConfiguration : IEntityTypeConfiguration<ScorelineRecord>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ScorelineRecord> builder)
|
||||
{
|
||||
builder.ConfigureTenantEntity("scoreline_records");
|
||||
builder.ConfigureTimestamps();
|
||||
builder.Property(entity => entity.LegacyId).HasMaxLength(64);
|
||||
builder.Property(entity => entity.SchoolName).HasMaxLength(300);
|
||||
builder.Property(entity => entity.MajorName).HasMaxLength(300);
|
||||
builder.Property(entity => entity.FieldValues).IsJson("{}");
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.LegacyId }).IsUnique();
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.RegionId, entity.SchoolId, entity.MajorId, entity.Year });
|
||||
builder.HasIndex(entity => new { entity.TenantId, entity.Year });
|
||||
|
||||
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);
|
||||
builder.HasOne<Major>()
|
||||
.WithMany()
|
||||
.HasForeignKey(entity => new { entity.TenantId, entity.MajorId })
|
||||
.HasPrincipalKey(entity => new { entity.TenantId, entity.Id })
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
}
|
||||
}
|
||||
15554
Tiku.Infrastructure/Persistence/Migrations/20260726102813_AddScorelineDynamicRecords.Designer.cs
generated
Normal file
15554
Tiku.Infrastructure/Persistence/Migrations/20260726102813_AddScorelineDynamicRecords.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddScorelineDynamicRecords : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "scoreline_fields",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
region_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
field_key = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
|
||||
field_name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
field_type = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
||||
unit = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: true),
|
||||
is_filter = table.Column<bool>(type: "boolean", nullable: false),
|
||||
is_required = table.Column<bool>(type: "boolean", nullable: false),
|
||||
is_visible = table.Column<bool>(type: "boolean", nullable: false),
|
||||
is_trend = table.Column<bool>(type: "boolean", nullable: false),
|
||||
options = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'[]'::jsonb"),
|
||||
placeholder = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: true),
|
||||
description = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
sort_order = table.Column<int>(type: "integer", nullable: false),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_scoreline_fields", x => x.id);
|
||||
table.UniqueConstraint("ak_scoreline_fields_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_scoreline_fields_regions_tenant_id_region_id",
|
||||
columns: x => new { x.tenant_id, x.region_id },
|
||||
principalTable: "regions",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_scoreline_fields_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "scoreline_records",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||
region_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
school_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
major_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
legacy_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
year = table.Column<int>(type: "integer", nullable: false),
|
||||
school_name = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
|
||||
major_name = table.Column<string>(type: "character varying(300)", maxLength: 300, nullable: true),
|
||||
field_values = table.Column<JsonElement>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||
tenant_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||
updated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_scoreline_records", x => x.id);
|
||||
table.UniqueConstraint("ak_scoreline_records_tenant_id_id", x => new { x.tenant_id, x.id });
|
||||
table.ForeignKey(
|
||||
name: "fk_scoreline_records_majors_tenant_id_major_id",
|
||||
columns: x => new { x.tenant_id, x.major_id },
|
||||
principalTable: "majors",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_scoreline_records_regions_tenant_id_region_id",
|
||||
columns: x => new { x.tenant_id, x.region_id },
|
||||
principalTable: "regions",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_scoreline_records_schools_tenant_id_school_id",
|
||||
columns: x => new { x.tenant_id, x.school_id },
|
||||
principalTable: "schools",
|
||||
principalColumns: new[] { "tenant_id", "id" },
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "fk_scoreline_records_tenants_tenant_id",
|
||||
column: x => x.tenant_id,
|
||||
principalTable: "tenants",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_fields_tenant_id_legacy_id",
|
||||
table: "scoreline_fields",
|
||||
columns: new[] { "tenant_id", "legacy_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_fields_tenant_id_region_id_field_key",
|
||||
table: "scoreline_fields",
|
||||
columns: new[] { "tenant_id", "region_id", "field_key" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_fields_tenant_id_region_id_is_filter_sort_order",
|
||||
table: "scoreline_fields",
|
||||
columns: new[] { "tenant_id", "region_id", "is_filter", "sort_order" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_records_tenant_id_legacy_id",
|
||||
table: "scoreline_records",
|
||||
columns: new[] { "tenant_id", "legacy_id" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_records_tenant_id_major_id",
|
||||
table: "scoreline_records",
|
||||
columns: new[] { "tenant_id", "major_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~",
|
||||
table: "scoreline_records",
|
||||
columns: new[] { "tenant_id", "region_id", "school_id", "major_id", "year" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_records_tenant_id_school_id",
|
||||
table: "scoreline_records",
|
||||
columns: new[] { "tenant_id", "school_id" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_scoreline_records_tenant_id_year",
|
||||
table: "scoreline_records",
|
||||
columns: new[] { "tenant_id", "year" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "scoreline_fields");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "scoreline_records");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -546,6 +546,204 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
b.ToTable("schools", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineField", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("FieldKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("field_key");
|
||||
|
||||
b.Property<string>("FieldName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("field_name");
|
||||
|
||||
b.Property<string>("FieldType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("field_type");
|
||||
|
||||
b.Property<bool>("IsFilter")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_filter");
|
||||
|
||||
b.Property<bool>("IsRequired")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_required");
|
||||
|
||||
b.Property<bool>("IsTrend")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_trend");
|
||||
|
||||
b.Property<bool>("IsVisible")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_visible");
|
||||
|
||||
b.Property<string>("LegacyId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("legacy_id");
|
||||
|
||||
b.Property<JsonElement>("Options")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("options")
|
||||
.HasDefaultValueSql("'[]'::jsonb");
|
||||
|
||||
b.Property<string>("Placeholder")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("placeholder");
|
||||
|
||||
b.Property<Guid?>("RegionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("region_id");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)")
|
||||
.HasColumnName("unit");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_scoreline_fields");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_scoreline_fields_tenant_id_id");
|
||||
|
||||
b.HasIndex("TenantId", "LegacyId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_scoreline_fields_tenant_id_legacy_id");
|
||||
|
||||
b.HasIndex("TenantId", "RegionId", "FieldKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_field_key");
|
||||
|
||||
b.HasIndex("TenantId", "RegionId", "IsFilter", "SortOrder")
|
||||
.HasDatabaseName("ix_scoreline_fields_tenant_id_region_id_is_filter_sort_order");
|
||||
|
||||
b.ToTable("scoreline_fields", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<JsonElement>("FieldValues")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("field_values")
|
||||
.HasDefaultValueSql("'{}'::jsonb");
|
||||
|
||||
b.Property<string>("LegacyId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("legacy_id");
|
||||
|
||||
b.Property<Guid?>("MajorId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("major_id");
|
||||
|
||||
b.Property<string>("MajorName")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)")
|
||||
.HasColumnName("major_name");
|
||||
|
||||
b.Property<Guid?>("RegionId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("region_id");
|
||||
|
||||
b.Property<Guid?>("SchoolId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("school_id");
|
||||
|
||||
b.Property<string>("SchoolName")
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("character varying(300)")
|
||||
.HasColumnName("school_name");
|
||||
|
||||
b.Property<Guid>("TenantId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("tenant_id");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at")
|
||||
.HasDefaultValueSql("now()");
|
||||
|
||||
b.Property<int>("Year")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("year");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_scoreline_records");
|
||||
|
||||
b.HasAlternateKey("TenantId", "Id")
|
||||
.HasName("ak_scoreline_records_tenant_id_id");
|
||||
|
||||
b.HasIndex("TenantId", "LegacyId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_scoreline_records_tenant_id_legacy_id");
|
||||
|
||||
b.HasIndex("TenantId", "MajorId")
|
||||
.HasDatabaseName("ix_scoreline_records_tenant_id_major_id");
|
||||
|
||||
b.HasIndex("TenantId", "SchoolId")
|
||||
.HasDatabaseName("ix_scoreline_records_tenant_id_school_id");
|
||||
|
||||
b.HasIndex("TenantId", "Year")
|
||||
.HasDatabaseName("ix_scoreline_records_tenant_id_year");
|
||||
|
||||
b.HasIndex("TenantId", "RegionId", "SchoolId", "MajorId", "Year")
|
||||
.HasDatabaseName("ix_scoreline_records_tenant_id_region_id_school_id_major_id_ye~");
|
||||
|
||||
b.ToTable("scoreline_records", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Catalog.Subject", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -12499,6 +12697,54 @@ namespace Tiku.Infrastructure.Persistence.Migrations
|
||||
.HasConstraintName("fk_schools_regions_tenant_id_region_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineField", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_scoreline_fields_tenants_tenant_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Catalog.Region", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "RegionId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("fk_scoreline_fields_regions_tenant_id_region_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Catalog.ScorelineRecord", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_scoreline_records_tenants_tenant_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Catalog.Major", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "MajorId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("fk_scoreline_records_majors_tenant_id_major_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Catalog.Region", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "RegionId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("fk_scoreline_records_regions_tenant_id_region_id");
|
||||
|
||||
b.HasOne("Tiku.Domain.Catalog.School", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TenantId", "SchoolId")
|
||||
.HasPrincipalKey("TenantId", "Id")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.HasConstraintName("fk_scoreline_records_schools_tenant_id_school_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Tiku.Domain.Catalog.Subject", b =>
|
||||
{
|
||||
b.HasOne("Tiku.Domain.Tenancy.Tenant", null)
|
||||
|
||||
@@ -41,6 +41,8 @@ public sealed class TikuDbContext(DbContextOptions<TikuDbContext> options) : DbC
|
||||
public DbSet<Major> Majors => Set<Major>();
|
||||
public DbSet<Subject> Subjects => Set<Subject>();
|
||||
public DbSet<Category> Categories => Set<Category>();
|
||||
public DbSet<ScorelineField> ScorelineFields => Set<ScorelineField>();
|
||||
public DbSet<ScorelineRecord> ScorelineRecords => Set<ScorelineRecord>();
|
||||
public DbSet<QuestionBank> QuestionBanks => Set<QuestionBank>();
|
||||
public DbSet<Question> Questions => Set<Question>();
|
||||
public DbSet<QuestionVersion> QuestionVersions => Set<QuestionVersion>();
|
||||
|
||||
268
Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs
Normal file
268
Tiku.Infrastructure/Scoreline/ScorelineQueryService.cs
Normal file
@@ -0,0 +1,268 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Catalog;
|
||||
using Tiku.Application.Scoreline;
|
||||
using Tiku.Domain.Catalog;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
|
||||
namespace Tiku.Infrastructure.Scoreline;
|
||||
|
||||
public sealed partial class ScorelineQueryService(TikuDbContext dbContext) : IScorelineQueryService
|
||||
{
|
||||
public async Task<CatalogList<ScorelineFieldItem>> GetFieldsAsync(
|
||||
ScorelineFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = dbContext.ScorelineFields.AsNoTracking()
|
||||
.Where(field => field.TenantId == filter.TenantId);
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(field => field.RegionId == filter.RegionId.Value || field.RegionId == null);
|
||||
}
|
||||
|
||||
var fields = await query
|
||||
.OrderBy(field => field.SortOrder)
|
||||
.ThenBy(field => field.FieldName)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<ScorelineFieldItem>(fields.Select(ToFieldItem).ToArray());
|
||||
}
|
||||
|
||||
public async Task<ScorelineRecordPage> GetRecordsAsync(
|
||||
ScorelineFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var page = Math.Max(filter.Page ?? 1, 1);
|
||||
var pageSize = Math.Clamp(filter.PageSize ?? 20, 1, 200);
|
||||
var records = await BuildRecordQuery(filter)
|
||||
.OrderByDescending(record => record.Year)
|
||||
.ThenBy(record => record.SchoolName)
|
||||
.ThenBy(record => record.MajorName)
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
var filtered = await ApplyDynamicFiltersAsync(filter, records, cancellationToken);
|
||||
var items = filtered
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(ToRecordItem)
|
||||
.ToArray();
|
||||
|
||||
return new ScorelineRecordPage(page, pageSize, filtered.Count, items);
|
||||
}
|
||||
|
||||
public async Task<CatalogList<ScorelineRecordItem>> GetTrendAsync(
|
||||
ScorelineFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var records = await BuildRecordQuery(filter)
|
||||
.OrderBy(record => record.Year)
|
||||
.ThenBy(record => record.MajorName)
|
||||
.Take(ResolveLimit(filter.Limit, 100))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var filtered = await ApplyDynamicFiltersAsync(filter, records, cancellationToken);
|
||||
return new CatalogList<ScorelineRecordItem>(filtered.Select(ToRecordItem).ToArray());
|
||||
}
|
||||
|
||||
public async Task<CatalogList<int>> GetYearsAsync(
|
||||
ScorelineFilter filter,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var years = await BuildRecordQuery(filter)
|
||||
.Select(record => record.Year)
|
||||
.Distinct()
|
||||
.OrderByDescending(year => year)
|
||||
.Take(ResolveLimit(filter.Limit))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
|
||||
return new CatalogList<int>(years);
|
||||
}
|
||||
|
||||
private IQueryable<ScorelineRecord> BuildRecordQuery(ScorelineFilter filter)
|
||||
{
|
||||
var query = dbContext.ScorelineRecords.AsNoTracking()
|
||||
.Where(record => record.TenantId == filter.TenantId);
|
||||
if (filter.RegionId.HasValue)
|
||||
{
|
||||
query = query.Where(record => record.RegionId == filter.RegionId.Value);
|
||||
}
|
||||
|
||||
if (filter.SchoolId.HasValue)
|
||||
{
|
||||
query = query.Where(record => record.SchoolId == filter.SchoolId.Value);
|
||||
}
|
||||
|
||||
if (filter.MajorId.HasValue)
|
||||
{
|
||||
query = query.Where(record => record.MajorId == filter.MajorId.Value);
|
||||
}
|
||||
|
||||
if (filter.Year.HasValue)
|
||||
{
|
||||
query = query.Where(record => record.Year == filter.Year.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(filter.Keyword))
|
||||
{
|
||||
var keyword = filter.Keyword.Trim();
|
||||
query = query.Where(record =>
|
||||
(record.SchoolName != null && record.SchoolName.Contains(keyword)) ||
|
||||
(record.MajorName != null && record.MajorName.Contains(keyword)));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ScorelineRecord>> ApplyDynamicFiltersAsync(
|
||||
ScorelineFilter filter,
|
||||
IReadOnlyCollection<ScorelineRecord> records,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (filter.DynamicFilters is null || filter.DynamicFilters.Count == 0)
|
||||
{
|
||||
return records.ToArray();
|
||||
}
|
||||
|
||||
var allowedFields = await dbContext.ScorelineFields.AsNoTracking()
|
||||
.Where(field =>
|
||||
field.TenantId == filter.TenantId &&
|
||||
field.IsFilter &&
|
||||
(filter.RegionId == null || field.RegionId == filter.RegionId || field.RegionId == null))
|
||||
.ToArrayAsync(cancellationToken);
|
||||
var allowed = allowedFields
|
||||
.GroupBy(field => field.FieldKey, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.OrderBy(field => field.SortOrder).First(), StringComparer.Ordinal);
|
||||
|
||||
foreach (var dynamicFilter in filter.DynamicFilters)
|
||||
{
|
||||
if (!FieldKeyRegex().IsMatch(dynamicFilter.FieldKey))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline field filter key is invalid.", "scoreline_field_filter_key_invalid");
|
||||
}
|
||||
|
||||
if (!allowed.ContainsKey(dynamicFilter.FieldKey))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline field filter is not enabled.", "scoreline_field_filter_not_allowed");
|
||||
}
|
||||
}
|
||||
|
||||
return records
|
||||
.Where(record => filter.DynamicFilters.All(dynamicFilter => MatchDynamicFilter(record, allowed[dynamicFilter.FieldKey], dynamicFilter)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static bool MatchDynamicFilter(
|
||||
ScorelineRecord record,
|
||||
ScorelineField field,
|
||||
ScorelineDynamicFilter filter)
|
||||
{
|
||||
if (record.FieldValues.ValueKind != JsonValueKind.Object ||
|
||||
!record.FieldValues.TryGetProperty(filter.FieldKey, out var value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var fieldType = field.FieldType.Trim().ToLowerInvariant();
|
||||
if (filter.Operator is "min" or "max")
|
||||
{
|
||||
if (!IsNumericType(fieldType))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline range filter requires a numeric field.", "scoreline_field_range_type_invalid");
|
||||
}
|
||||
|
||||
if (!TryGetDecimal(value, out var actual) || !decimal.TryParse(filter.Value, out var expected))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline range filter value must be numeric.", "scoreline_field_range_value_invalid");
|
||||
}
|
||||
|
||||
return filter.Operator == "min" ? actual >= expected : actual <= expected;
|
||||
}
|
||||
|
||||
if (IsNumericType(fieldType))
|
||||
{
|
||||
if (!TryGetDecimal(value, out var actual) || !decimal.TryParse(filter.Value, out var expected))
|
||||
{
|
||||
throw new ScorelineQueryException("Scoreline numeric filter value must be numeric.", "scoreline_field_value_invalid");
|
||||
}
|
||||
|
||||
return actual == expected;
|
||||
}
|
||||
|
||||
var actualText = value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString();
|
||||
return fieldType switch
|
||||
{
|
||||
"text" or "textarea" or "string" => actualText?.Contains(filter.Value, StringComparison.OrdinalIgnoreCase) == true,
|
||||
"boolean" or "bool" => string.Equals(actualText, filter.Value, StringComparison.OrdinalIgnoreCase),
|
||||
_ => string.Equals(actualText, filter.Value, StringComparison.OrdinalIgnoreCase)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsNumericType(string fieldType)
|
||||
{
|
||||
return fieldType is "number" or "integer" or "decimal" or "float";
|
||||
}
|
||||
|
||||
private static bool TryGetDecimal(JsonElement value, out decimal result)
|
||||
{
|
||||
if (value.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
return value.TryGetDecimal(out result);
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
return decimal.TryParse(value.GetString(), out result);
|
||||
}
|
||||
|
||||
result = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ScorelineFieldItem ToFieldItem(ScorelineField field)
|
||||
{
|
||||
return new ScorelineFieldItem(
|
||||
field.Id,
|
||||
field.LegacyId,
|
||||
field.RegionId,
|
||||
field.FieldKey,
|
||||
field.FieldName,
|
||||
field.FieldType,
|
||||
field.Unit,
|
||||
field.IsFilter,
|
||||
field.IsRequired,
|
||||
field.IsVisible,
|
||||
field.IsTrend,
|
||||
field.Options,
|
||||
field.Placeholder,
|
||||
field.Description,
|
||||
field.SortOrder);
|
||||
}
|
||||
|
||||
private static ScorelineRecordItem ToRecordItem(ScorelineRecord record)
|
||||
{
|
||||
return new ScorelineRecordItem(
|
||||
record.Id,
|
||||
record.LegacyId,
|
||||
record.RegionId,
|
||||
record.SchoolId,
|
||||
record.MajorId,
|
||||
record.Year,
|
||||
record.SchoolName,
|
||||
record.MajorName,
|
||||
record.FieldValues);
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int? limit, int fallback = 500)
|
||||
{
|
||||
return Math.Clamp(limit ?? fallback, 1, 2000);
|
||||
}
|
||||
|
||||
[GeneratedRegex("^[A-Za-z][A-Za-z0-9_]{0,63}$")]
|
||||
private static partial Regex FieldKeyRegex();
|
||||
}
|
||||
|
||||
public sealed class ScorelineQueryException(string message, string code) : Exception(message)
|
||||
{
|
||||
public string Code { get; } = code;
|
||||
}
|
||||
Reference in New Issue
Block a user