using System.Collections; using System.Diagnostics; using System.Security.Cryptography; using System.Text.Json; namespace JsonContextDb.JsonContext; /// /// A lightweight, JSON-based data context that emulates Entity Framework Core behavior. /// Provides thread-safe methods to query and modify entities stored in JSON files, suitable for small datasets. /// Entities are managed in-memory and persisted to JSON files on disk. /// /// /// Designed to be used as a singleton to minimize file I/O, loading JSON files once at initialization. /// All in-memory operations are thread-safe using a lock. File reads are synchronous for simplicity, while file writes /// are asynchronous to avoid blocking. The context mimics EF Core's API, including for querying /// and for persisting changes. Entities must have an integer Id property. /// Copyright (c) 2025 Alphons van der Heijden. All rights reserved. /// public class DbContext(string? dataDirectory, DbContextOptions? options = null) { // Directory where JSON files are stored, required and validated. private readonly string dataDirectory = dataDirectory ?? throw new ArgumentNullException(nameof(dataDirectory)); // Configuration options for serialization and file naming. private readonly DbContextOptions jsonContextOptions = options ?? new DbContextOptions(); // In-memory storage of entity lists, keyed by entity type. private readonly Dictionary entityLists = []; // Tracks pending changes (add, update, remove) for SaveChangesAsync. private readonly List<(Type Type, object Entity, ActionType Action)> changes = []; // Stores serialized snapshots of entities for change tracking, using SHA-256 hashes to detect modifications. private readonly Dictionary snapshots = []; // Synchronization object for thread-safe operations. private readonly object lockObject = new(); // Cache of compiled ID accessors for entities, improving performance. private static readonly Dictionary> IdAccessors = []; public enum ActionType { Unknown, Add, Update, Remove } /// /// Loads the list of entities of type from the corresponding JSON file. /// /// The type of entity, which must be a class with an integer Id property. /// A containing the deserialized entities, or an empty list if the file does not exist. /// /// The JSON file is named using the and located in the directory /// specified during construction. If the file does not exist or is corrupted, an empty list is returned or an exception /// is thrown. This method uses synchronous file I/O for simplicity and is called by to initialize /// the in-memory entity list. /// private List LoadEntities() where T : class { var jsonFilePath = Path.Combine(dataDirectory, jsonContextOptions.FileNameFactory(typeof(T))); var fi = new FileInfo(jsonFilePath); if (!fi.Exists) return []; try { using var stream = fi.OpenRead(); var entities = JsonSerializer.Deserialize>(stream, jsonContextOptions.SerializerOptions) ?? []; lock (lockObject) { foreach (var entity in entities) { snapshots[entity] = ComputeSHA256Hash(entity, jsonContextOptions.SerializerOptions); } } return entities; } catch (JsonException ex) { throw new InvalidOperationException($"Failed to deserialize JSON file '{jsonFilePath}'.", ex); } } /// /// Computes a SHA-256 hash of the serialized entity for change tracking. /// /// The entity to hash. /// The JSON serialization options. /// A 32-byte hash of the serialized entity. private static byte[] ComputeSHA256Hash(object entity, JsonSerializerOptions options) => SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(entity, options)); // 32 bytes output // Retrieves a queryable set of entities of type T public DbSet Set() where T : class => new(this); internal List GetList() where T : class { if (!entityLists.ContainsKey(typeof(T))) entityLists[typeof(T)] = LoadEntities(); return entityLists[typeof(T)] as List ?? throw new Exception($"LoadEntities returned null on {nameof(T)}"); } private IList GetList(Type type) { if (!entityLists.TryGetValue(type, out IList? list)) throw new Exception($"Collection {type} disapeared"); return list; } /// /// Retrieves the Id property value from an entity. /// /// The entity from which to retrieve the Id. /// The integer value of the Id property. /// Thrown when the entity lacks an Id property or the Id is null. /// /// This method uses a cached accessor to avoid repeated reflection. It assumes all entities have an integer /// Id property, which is required for matching entities in the in-memory list. /// private static int GetId(object entity) { var type = entity.GetType(); // Retrieve or create a cached accessor for the Id property. if (!IdAccessors.TryGetValue(type, out var accessor)) { var idProperty = type.GetProperty("Id") ?? throw new InvalidOperationException("Entity must have an Id property."); accessor = (obj) => (int)(idProperty.GetValue(obj) ?? throw new InvalidOperationException("Id cannot be null.")); IdAccessors[type] = accessor; } return accessor(entity); } /// /// Retrieves or creates the for an entity type and returns the next available ID. /// Updates the directly in the in-memory list. /// /// The next available ID for the entity type. /// /// This method is thread-safe, ensuring that ID generation is atomic and consistent across concurrent calls. /// If no exists for the entity type, a new entry is created with an initial ID of 1. /// private int GetNextIdForEntityType(Type t) { var typeKey = t.Name; var metaDataList = GetList(); var metaData = metaDataList.FirstOrDefault(m => m.EntityType == typeKey); if (metaData == null) { metaData = new MetaData { Id = metaDataList.Count > 0 ? metaDataList.Max(m => m.Id) + 1 : 1, EntityType = typeKey, NextId = 1 }; metaDataList.Add(metaData); } int nextId = metaData.NextId; metaData.NextId++; return nextId; } private static void SetId(object entity, int id) { var type = entity.GetType(); var idProperty = type.GetProperty("Id") ?? throw new InvalidOperationException("Entity must have an Id property."); idProperty.SetValue(entity, id); } /// /// Marks an entity for addition to the data context, assigning a new ID if none is set. /// /// The entity to add. /// Thrown if is null. /// /// This method is thread-safe, using a lock to ensure atomic operations. If the entity's Id is 0, /// a new ID is generated using . A snapshot is created for change tracking. /// Only accessible via . /// internal void Add(T entity) where T : class { ArgumentNullException.ThrowIfNull(entity); lock (lockObject) { //var list = GetList(); //list.Add(entity); changes.Add((typeof(T), entity, ActionType.Add)); snapshots[entity] = ComputeSHA256Hash(entity, jsonContextOptions.SerializerOptions); } } /// /// Marks multiple entities for addition to the data context. Only accessible via . /// /// The entities to add. /// Thrown if or any entity is null. internal void AddRange(IEnumerable entities) where T : class { ArgumentNullException.ThrowIfNull(entities); lock (lockObject) { //var list = GetList(); foreach (var entity in entities) { ArgumentNullException.ThrowIfNull(entity); //list.Add(entity); changes.Add((typeof(T), entity, ActionType.Add)); snapshots[entity] = ComputeSHA256Hash(entity, jsonContextOptions.SerializerOptions); } } } /// /// Marks an entity for update in the data context. Only accessible via . /// /// The entity to update. /// Thrown if is null. internal void Update(T entity) where T : class { ArgumentNullException.ThrowIfNull(entity); lock (lockObject) { // Check if entity has changed by comparing serialized snapshots. if (snapshots.TryGetValue(entity, out var snapshot)) { var current = ComputeSHA256Hash(entity, jsonContextOptions.SerializerOptions); if (!snapshot.SequenceEqual(current)) // Only update if changed { changes.Add((typeof(T), entity, ActionType.Update)); snapshots[entity] = current; } } else { // New entity, track for update and store snapshot. changes.Add((typeof(T), entity, ActionType.Update)); snapshots[entity] = ComputeSHA256Hash(entity, jsonContextOptions.SerializerOptions); } } } /// /// Marks multiple entities for update in the data context. Only accessible via . /// /// The entities to update. /// Thrown if or any entity is null. internal void UpdateRange(IEnumerable entities) where T : class { ArgumentNullException.ThrowIfNull(entities); lock (lockObject) { // Update each entity individually. foreach (var entity in entities) { ArgumentNullException.ThrowIfNull(entity); Update(entity); } } } /// /// Marks an entity for removal from the data context. Only accessible via . /// /// The entity to remove. /// Thrown if is null. internal void Remove(T entity) where T : class { ArgumentNullException.ThrowIfNull(entity); lock (lockObject) { // Track entity for removal and remove its snapshot. changes.Add((typeof(T), entity, ActionType.Remove)); snapshots.Remove(entity); } } /// /// Marks multiple entities for removal from the data context. Only accessible via . /// /// The entities to remove. /// Thrown if or any entity is null. internal void RemoveRange(IEnumerable entities) where T : class { ArgumentNullException.ThrowIfNull(entities); lock (lockObject) { // Track each entity for removal and remove its snapshot. foreach (var entity in entities) { ArgumentNullException.ThrowIfNull(entity); changes.Add((typeof(T), entity, ActionType.Remove)); snapshots.Remove(entity); } } } /// /// Asynchronously saves all pending changes to the JSON files and returns the number of affected entities. /// /// A task representing the asynchronous operation, returning the number of entities successfully added, updated, or removed. /// /// Applies pending changes to in-memory lists within a lock for thread-safety, then persists to JSON files asynchronously. /// If a file write fails (e.g., due to access conflicts or I/O errors), in-memory changes are rolled back to maintain consistency, /// and pending changes are preserved for retry. Concurrent modifications to the same entity type may lead to overwrites; /// use with caution in high-concurrency scenarios. /// public async Task SaveChangesAsync() { Dictionary filesToWrite = []; int affectedEntities = 0; Dictionary entityListsBackup; HashSet modifiedTypes; List<(Type Type, object Entity, ActionType Action)> changesBackup; lock (lockObject) { changes.Add((typeof(MetaData), new MetaData(), ActionType.Unknown)); entityListsBackup = entityLists.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); modifiedTypes = [.. changes.Select(c => c.Type)]; changesBackup = [.. changes]; foreach (var kvp in entityLists) { var type = kvp.Key; var entities = kvp.Value; foreach (var entity in entities) { if (snapshots.TryGetValue(entity, out var snapshot)) { var current = ComputeSHA256Hash(entity, jsonContextOptions.SerializerOptions); if (!snapshot.SequenceEqual(current) && !changes.Any(c => c.Entity == entity)) { changes.Add((type, entity, ActionType.Update)); snapshots[entity] = current; modifiedTypes.Add(type); } } } } foreach (var group in changes.GroupBy(c => c.Type)) { var type = group.Key; var entities = GetList(type); foreach (var change in group) { if (change.Action == ActionType.Add) { SetId(change.Entity, GetNextIdForEntityType(type)); snapshots[change.Entity] = ComputeSHA256Hash(change.Entity, jsonContextOptions.SerializerOptions); entities.Add(change.Entity); if (typeof(MetaData) != change.Entity.GetType()) affectedEntities++; } else if (change.Action == ActionType.Update) { var id = GetId(change.Entity); var existing = entities.Cast().FirstOrDefault(e => GetId(e) == id); if (existing != null) { entities.Remove(existing); entities.Add(change.Entity); if (typeof(MetaData) != change.Entity.GetType()) affectedEntities++; } } else if (change.Action == ActionType.Remove) { entities.Remove(change.Entity); affectedEntities++; } } if (modifiedTypes.Contains(type)) { var jsonFilePath = Path.Combine(dataDirectory, jsonContextOptions.FileNameFactory(type)); var json = JsonSerializer.Serialize(entities, jsonContextOptions.SerializerOptions); filesToWrite[type] = (jsonFilePath, json); } } } try { foreach (var (filePath, json) in filesToWrite.Values) { var tempFilePath = filePath + ".tmp"; await File.WriteAllTextAsync(tempFilePath, json); File.Move(tempFilePath, filePath, overwrite: true); } filesToWrite.Clear(); filesToWrite.TrimExcess(); changes.Clear(); changes.TrimExcess(); } catch (Exception ex) { lock (lockObject) { entityLists.Clear(); foreach (var kvp in entityListsBackup) { entityLists[kvp.Key] = kvp.Value; } changes.AddRange(changesBackup); } throw new InvalidOperationException("Failed to save changes. Changes rolled back, pending changes preserved.", ex); } Debug.WriteLine($"AffectedEntities:{affectedEntities}"); return affectedEntities; } }