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;
}
}