141 lines
5.8 KiB
C#
141 lines
5.8 KiB
C#
using System.Text.Json;
|
|
|
|
namespace SVSim.Bootstrap.Importers;
|
|
|
|
/// <summary>
|
|
/// Shared helpers for content importers. Loads a prod-capture JSON file by endpoint name from
|
|
/// a captures directory, returning the inner <c>data</c> element. Picks the latest matching dated
|
|
/// file (e.g. <c>load-index-2026-05-23.json</c>) if multiple exist for the same endpoint.
|
|
/// </summary>
|
|
public static class ImporterBase
|
|
{
|
|
/// <summary>
|
|
/// Returns the parsed <c>.data</c> JsonElement for the latest <c>{endpoint}-*.json</c> file in
|
|
/// <paramref name="capturesDir"/>, or null if no file matches. Logs a warning when missing —
|
|
/// caller decides whether absence is fatal.
|
|
/// </summary>
|
|
public static JsonElement? LoadCapture(string capturesDir, string endpoint)
|
|
{
|
|
if (!Directory.Exists(capturesDir))
|
|
{
|
|
Console.Error.WriteLine($"[ImporterBase] Captures dir missing: {capturesDir}");
|
|
return null;
|
|
}
|
|
|
|
string? path = Directory.EnumerateFiles(capturesDir, $"{endpoint}-*.json")
|
|
.OrderByDescending(p => p)
|
|
.FirstOrDefault();
|
|
|
|
if (path is null)
|
|
{
|
|
Console.Error.WriteLine($"[ImporterBase] No capture found for endpoint '{endpoint}' in {capturesDir}");
|
|
return null;
|
|
}
|
|
|
|
using var fs = File.OpenRead(path);
|
|
using var doc = JsonDocument.Parse(fs);
|
|
if (!doc.RootElement.TryGetProperty("data", out var data))
|
|
{
|
|
Console.Error.WriteLine($"[ImporterBase] Capture file {path} has no top-level 'data' property.");
|
|
return null;
|
|
}
|
|
// Clone so the JsonElement survives doc disposal.
|
|
return data.Clone();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generic upsert by primary key. Returns (created, updated, unchanged) counts.
|
|
/// <paramref name="incoming"/> is the desired state from the capture; rows are matched by
|
|
/// <paramref name="keySelector"/>. <paramref name="applyChanges"/> mutates an existing row to
|
|
/// reflect incoming values and returns true if anything actually changed.
|
|
/// </summary>
|
|
public static (int created, int updated, int unchanged) Upsert<T, TKey>(
|
|
IEnumerable<T> incoming,
|
|
Dictionary<TKey, T> existingByKey,
|
|
Func<T, TKey> keySelector,
|
|
Action<T> addToContext,
|
|
Func<T, T, bool> applyChanges) where TKey : notnull
|
|
{
|
|
int created = 0, updated = 0, unchanged = 0;
|
|
foreach (var item in incoming)
|
|
{
|
|
var key = keySelector(item);
|
|
if (existingByKey.TryGetValue(key, out var existing))
|
|
{
|
|
if (applyChanges(existing, item)) updated++;
|
|
else unchanged++;
|
|
}
|
|
else
|
|
{
|
|
addToContext(item);
|
|
existingByKey[key] = item;
|
|
created++;
|
|
}
|
|
}
|
|
return (created, updated, unchanged);
|
|
}
|
|
|
|
/// <summary>Serialize a JsonElement back to compact JSON text for jsonb storage.</summary>
|
|
public static string Serialize(JsonElement el) =>
|
|
JsonSerializer.Serialize(el, new JsonSerializerOptions { WriteIndented = false });
|
|
|
|
/// <summary>Parse a wire date that may be ISO ("2026-05-23T..."), space-separated ("2026-05-23 16:32:31"), or empty.</summary>
|
|
public static DateTime ParseWireDateTime(string? s)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(s)) return DateTime.MinValue;
|
|
if (DateTime.TryParse(s, System.Globalization.CultureInfo.InvariantCulture,
|
|
System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal,
|
|
out var dt))
|
|
{
|
|
return dt;
|
|
}
|
|
return DateTime.MinValue;
|
|
}
|
|
|
|
/// <summary>Read a JsonElement string/number property as long, defaulting on missing/null.</summary>
|
|
public static long GetLong(JsonElement el, string prop, long fallback = 0)
|
|
{
|
|
if (!el.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return fallback;
|
|
if (v.ValueKind == JsonValueKind.Number) return v.GetInt64();
|
|
if (v.ValueKind == JsonValueKind.String && long.TryParse(v.GetString(), out var n)) return n;
|
|
return fallback;
|
|
}
|
|
|
|
public static int GetInt(JsonElement el, string prop, int fallback = 0)
|
|
{
|
|
if (!el.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return fallback;
|
|
if (v.ValueKind == JsonValueKind.Number) return v.GetInt32();
|
|
if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out var n)) return n;
|
|
return fallback;
|
|
}
|
|
|
|
public static string GetString(JsonElement el, string prop, string fallback = "")
|
|
{
|
|
if (!el.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return fallback;
|
|
return v.ValueKind == JsonValueKind.String ? v.GetString() ?? fallback : v.ToString();
|
|
}
|
|
|
|
public static bool GetBool(JsonElement el, string prop, bool fallback = false)
|
|
{
|
|
if (!el.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return fallback;
|
|
if (v.ValueKind == JsonValueKind.True) return true;
|
|
if (v.ValueKind == JsonValueKind.False) return false;
|
|
if (v.ValueKind == JsonValueKind.Number) return v.GetInt32() != 0;
|
|
if (v.ValueKind == JsonValueKind.String)
|
|
{
|
|
var s = v.GetString();
|
|
if (bool.TryParse(s, out var b)) return b;
|
|
if (int.TryParse(s, out var i)) return i != 0;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
public static ulong GetULong(JsonElement el, string prop, ulong fallback = 0)
|
|
{
|
|
if (!el.TryGetProperty(prop, out var v) || v.ValueKind == JsonValueKind.Null) return fallback;
|
|
if (v.ValueKind == JsonValueKind.Number) return v.GetUInt64();
|
|
if (v.ValueKind == JsonValueKind.String && ulong.TryParse(v.GetString(), out var n)) return n;
|
|
return fallback;
|
|
}
|
|
}
|