SEARCH-1: separator-insensitive NameSearch + OriginBreeder (Herkunft) filter

- Gerbil.NameSearch (lowercase, strip whitespace/.-_) auto-synced in ApplicationContext
  .SaveChanges; Gridify-filterable so 'clan kleine chaoten' matches 'Clan-Kleine-Chaoten'
  (client strips separators the same way). GerbilSearch.Normalize shared helper.
- Gerbil.OriginBreeder (free-text Herkunft) on DTO+Input, set by the FEAT-8 import from the
  source Zucht (imported animals have no OriginContact). Gridify-filterable.
- GET /gerbils/breeders: distinct non-empty OriginBreeder values for the Herkunft dropdown.
- Migration AddSearchFields (+ NameSearch backfill SQL for existing rows).
This commit is contained in:
2026-06-06 09:01:12 +02:00
parent 0ee2b53b24
commit 09f11cb4e3
8 changed files with 1145 additions and 3 deletions

View File

@@ -18,6 +18,29 @@ public class ApplicationContext : DbContext
public DbSet<SaleContract> SaleContracts => Set<SaleContract>();
public DbSet<BreederSettings> BreederSettings => Set<BreederSettings>();
// Keep Gerbil.NameSearch in sync on every save (separator-insensitive search key),
// so it can never drift from Name regardless of which code path mutates the entity.
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
SyncNameSearch();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
SyncNameSearch();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
private void SyncNameSearch()
{
foreach (var entry in ChangeTracker.Entries<Gerbil>())
{
if (entry.State is EntityState.Added or EntityState.Modified)
entry.Entity.NameSearch = GerbilSearch.Normalize(entry.Entity.Name);
}
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Gerbil>(e =>

View File

@@ -24,7 +24,8 @@ namespace GerbilManagerWebAPI.Dtos
string? Genotype,
string? Notes,
string? ImportSource,
string? ExternalRef);
string? ExternalRef,
string? OriginBreeder);
public record LitterDto(
Guid Id,
@@ -69,7 +70,8 @@ namespace GerbilManagerWebAPI.Dtos
string? Genotype,
string? Notes,
string? ImportSource,
string? ExternalRef);
string? ExternalRef,
string? OriginBreeder);
public record LitterInput(
string Name,

View File

@@ -19,6 +19,13 @@ namespace GerbilManagerWebAPI.Endpoints
TypedResults.Ok(await db.Gerbils.AsNoTracking()
.ToPagedResultAsync(query, ToDto)));
// GET /gerbils/breeders — distinct non-empty Herkunft values for the Tiere filter dropdown
group.MapGet("/breeders", async (ApplicationContext db) =>
TypedResults.Ok(await db.Gerbils.AsNoTracking()
.Where(g => g.OriginBreeder != null && g.OriginBreeder != "")
.Select(g => g.OriginBreeder!)
.Distinct().OrderBy(b => b).ToListAsync()));
// GET /gerbils/{id}
group.MapGet("/{id:guid}", async Task<Results<Ok<GerbilDto>, NotFound>> (Guid id, ApplicationContext db) =>
{
@@ -87,11 +94,12 @@ namespace GerbilManagerWebAPI.Endpoints
g.Notes = i.Notes;
g.ImportSource = i.ImportSource;
g.ExternalRef = i.ExternalRef;
g.OriginBreeder = i.OriginBreeder;
}
internal static GerbilDto ToDto(Gerbil g) => new(
g.Id, g.Name, g.Gender, g.Status, g.LitterId, g.OriginContactId, g.ReceiverContactId,
g.EnclosureId, g.ColorVarietyId, g.DateOfBirth, g.DateOfDeath, g.CauseOfDeath,
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef);
g.GoHomeDate, g.Genotype, g.Notes, g.ImportSource, g.ExternalRef, g.OriginBreeder);
}
}

View File

@@ -176,6 +176,7 @@ namespace GerbilManagerWebAPI.Import
Genotype = ComposeGenotype(a.Genotype),
ImportSource = ImportSourceTag,
ExternalRef = a.Id,
OriginBreeder = string.IsNullOrWhiteSpace(a.Zucht) ? null : a.Zucht.Trim(),
RawImportData = JsonSerializer.Serialize(new
{
a.Genotype.RawGenotype,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GerbilManagerWebAPI.Migrations
{
/// <inheritdoc />
public partial class AddSearchFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "NameSearch",
table: "Gerbils",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "OriginBreeder",
table: "Gerbils",
type: "text",
nullable: true);
// Backfill NameSearch for existing rows (lowercase, strip whitespace/.-_),
// matching GerbilSearch.Normalize. New/updated rows stay in sync via
// ApplicationContext.SaveChanges.
migrationBuilder.Sql(
"UPDATE \"Gerbils\" SET \"NameSearch\" = lower(regexp_replace(\"Name\", '[[:space:]._-]', '', 'g'));");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NameSearch",
table: "Gerbils");
migrationBuilder.DropColumn(
name: "OriginBreeder",
table: "Gerbils");
}
}
}

View File

@@ -696,9 +696,15 @@ namespace GerbilManagerWebAPI.Migrations
.IsRequired()
.HasColumnType("text");
b.Property<string>("NameSearch")
.HasColumnType("text");
b.Property<string>("Notes")
.HasColumnType("text");
b.Property<string>("OriginBreeder")
.HasColumnType("text");
b.Property<Guid?>("OriginContactId")
.HasColumnType("uuid");

View File

@@ -50,5 +50,27 @@ namespace GerbilManagerWebAPI.Models
/// <summary>Raw import payload preserved verbatim (rawGenotype + unmappedTokens like
/// the Uw locus / WFNZ markers) so nothing from the spreadsheets is lost. JSON text.</summary>
public string? RawImportData { get; set; }
/// <summary>Breeder/Herkunft (cattery/Zucht) as free text — set by the FEAT-8 import from
/// the source Zucht (imported animals have no OriginContact). Gridify-filterable; the
/// distinct values back the Tiere "Herkunft" dropdown (GET /gerbils/breeders).</summary>
public string? OriginBreeder { get; set; }
/// <summary>Separator-insensitive search key: Name lowercased with whitespace/.-_ stripped.
/// Kept in sync automatically on save (see ApplicationContext.SaveChanges). Gridify-filterable
/// so "clan kleine chaoten" matches "Clan-Kleine-Chaoten" (client strips separators too).</summary>
public string? NameSearch { get; set; }
}
/// <summary>Shared normalisation for the separator-insensitive name search.</summary>
public static class GerbilSearch
{
public static string Normalize(string? name)
{
if (string.IsNullOrEmpty(name)) return "";
var chars = name.ToLowerInvariant()
.Where(c => !char.IsWhiteSpace(c) && c != '-' && c != '.' && c != '_');
return new string(chars.ToArray());
}
}
}