| | | 1 | | using ArturRios.Heimdall.Data.Configuration; |
| | | 2 | | using ArturRios.Heimdall.Domain.Entities; |
| | | 3 | | using ArturRios.Heimdall.Domain.Persistence; |
| | | 4 | | using Microsoft.EntityFrameworkCore; |
| | | 5 | | |
| | | 6 | | namespace ArturRios.Heimdall.Data.Persistence; |
| | | 7 | | |
| | | 8 | | /// <summary> |
| | | 9 | | /// <see cref="IErasedSubjects" /> on PostgreSQL: one <c>INSERT … ON CONFLICT</c> for the whole set. |
| | | 10 | | /// </summary> |
| | | 11 | | /// <remarks> |
| | | 12 | | /// <para> |
| | | 13 | | /// A tracked insert would fail on the primary key when a retried hard delete tombstones an id |
| | | 14 | | /// again, and checking first would race a concurrent retry. <c>ON CONFLICT (public_id) DO |
| | | 15 | | /// UPDATE</c> makes the database settle it: a new id is inserted, a known one keeps its row and |
| | | 16 | | /// takes the new <c>created_at</c>, so the grace period counts from the attempt that goes on to |
| | | 17 | | /// delete the row. |
| | | 18 | | /// </para> |
| | | 19 | | /// <para> |
| | | 20 | | /// The ids travel as arrays and are unnested, so a scope's members are tombstoned in one |
| | | 21 | | /// statement however many there are, and all or none of them are. The statement bypasses the |
| | | 22 | | /// change tracker; nothing in a hard delete reads the tombstones back. |
| | | 23 | | /// </para> |
| | | 24 | | /// </remarks> |
| | 21 | 25 | | public sealed class ErasedSubjects(AppDbContext context) : IErasedSubjects |
| | | 26 | | { |
| | | 27 | | public async Task RecordAsync(IReadOnlyCollection<ErasedSubject> subjects) |
| | | 28 | | { |
| | 13 | 29 | | if (subjects.Count == 0) |
| | | 30 | | { |
| | 0 | 31 | | return; |
| | | 32 | | } |
| | | 33 | | |
| | | 34 | | // The table name comes from the model, never from input, so building it into the SQL is safe; |
| | | 35 | | // the values stay parameters. |
| | 13 | 36 | | var table = context.Model.FindEntityType(typeof(ErasedSubject))!.GetSchemaQualifiedTableName(); |
| | | 37 | | |
| | | 38 | | // One row per id, as ON CONFLICT cannot touch the same row twice in one statement. |
| | 30 | 39 | | var distinct = subjects.DistinctBy(subject => subject.Id).ToList(); |
| | | 40 | | |
| | 13 | 41 | | var sql = |
| | 13 | 42 | | $$""" |
| | 13 | 43 | | INSERT INTO {{table}} (public_id, kind, created_at) |
| | 13 | 44 | | SELECT * FROM unnest({0}::uuid[], {1}::integer[], {2}::timestamptz[]) |
| | 13 | 45 | | ON CONFLICT (public_id) DO UPDATE SET created_at = EXCLUDED.created_at |
| | 13 | 46 | | """; |
| | | 47 | | |
| | 13 | 48 | | await context.Database.ExecuteSqlRawAsync( |
| | 13 | 49 | | sql, |
| | 16 | 50 | | distinct.Select(subject => subject.Id).ToArray(), |
| | 16 | 51 | | distinct.Select(subject => subject.Kind).ToArray(), |
| | 29 | 52 | | distinct.Select(subject => subject.CreatedAt).ToArray()); |
| | 13 | 53 | | } |
| | | 54 | | } |