Working also on live adds

alphons <alphons@heijden.com> 26 Apr 2025, 20:32
9626789a6ae1c4646da4a2a8a642c9dfbee0c1b8
4 files changed
  • JsonContextDb.JsonContext/FileSystemStorage.cs
  • JsonContextDb.JsonContext/JsonContext.cs
  • JsonContextDb.JsonContext/JsonContextOptions.cs
  • JsonContextDb.TestApp/Program.cs
diff --git a/JsonContextDb.JsonContext/FileSystemStorage.cs b/JsonContextDb.JsonContext/FileSystemStorage.cs
index b3ab921..1fbdff6 100644
--- a/JsonContextDb.JsonContext/FileSystemStorage.cs
+++ b/JsonContextDb.JsonContext/FileSystemStorage.cs
@@ -1,26 +1,104 @@
namespace JsonContextDb.JsonContext;
-// Defines an interface for file storage operations, abstracting file I/O for testability.
+/// <summary>
+/// Defines an interface for file storage operations, abstracting file I/O for testability and flexibility.
+/// </summary>
+/// <remarks>
+/// This interface is used by <see cref="JsonContext"/> to read and write JSON files, enabling mocking in unit tests
+/// and supporting alternative storage implementations. All methods are expected to handle file paths relative to
+/// the configured data directory.
+/// </remarks>
public interface IFileStorage
{
- string ReadText(string path); // Synchronous read
- Task<string> ReadTextAsync(string path); // Asynchronous read
+ /// <summary>
+ /// Reads the contents of a file synchronously as a string.
+ /// </summary>
+ /// <param name="path">The path to the file.</param>
+ /// <returns>The contents of the file as a string.</returns>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="path"/> is null.</exception>
+ /// <exception cref="IOException">Thrown if an I/O error occurs while reading the file.</exception>
+ /// <exception cref="UnauthorizedAccessException">Thrown if access to the file is denied.</exception>
+ string ReadText(string path);
+
+ /// <summary>
+ /// Reads the contents of a file asynchronously as a string.
+ /// </summary>
+ /// <param name="path">The path to the file.</param>
+ /// <returns>A task representing the asynchronous operation, returning the contents of the file as a string.</returns>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="path"/> is null.</exception>
+ /// <exception cref="IOException">Thrown if an I/O error occurs while reading the file.</exception>
+ /// <exception cref="UnauthorizedAccessException">Thrown if access to the file is denied.</exception>
+ Task<string> ReadTextAsync(string path);
+
+ /// <summary>
+ /// Writes the specified content to a file asynchronously.
+ /// </summary>
+ /// <param name="path">The path to the file.</param>
+ /// <param name="content">The content to write to the file.</param>
+ /// <returns>A task representing the asynchronous operation.</returns>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="path"/> or <paramref name="content"/> is null.</exception>
+ /// <exception cref="IOException">Thrown if an I/O error occurs while writing the file.</exception>
+ /// <exception cref="UnauthorizedAccessException">Thrown if access to the file is denied.</exception>
Task WriteTextAsync(string path, string content);
+
+ /// <summary>
+ /// Checks if a file exists at the specified path.
+ /// </summary>
+ /// <param name="path">The path to the file.</param>
+ /// <returns><c>true</c> if the file exists; otherwise, <c>false</c>.</returns>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="path"/> is null.</exception>
bool Exists(string path);
}
-// Concrete implementation of IFileStorage using the System.IO.File API.
+/// <summary>
+/// A concrete implementation of <see cref="IFileStorage"/> using the <see cref="System.IO.File"/> API.
+/// </summary>
+/// <remarks>
+/// This class provides file I/O operations for reading and writing JSON files in <see cref="JsonContext"/>.
+/// It uses the standard .NET file system APIs, performing synchronous reads for simplicity and asynchronous
+/// writes to avoid blocking. All operations are performed relative to the data directory configured in
+/// <see cref="JsonContext"/>.
+/// </remarks>
public class FileSystemStorage : IFileStorage
{
- // Reads text from a file synchronously.
+ /// <summary>
+ /// Reads the contents of a file synchronously as a string.
+ /// </summary>
+ /// <param name="path">The path to the file.</param>
+ /// <returns>The contents of the file as a string.</returns>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="path"/> is null.</exception>
+ /// <exception cref="IOException">Thrown if an I/O error occurs while reading the file.</exception>
+ /// <exception cref="UnauthorizedAccessException">Thrown if access to the file is denied.</exception>
+ /// <exception cref="FileNotFoundException">Thrown if the file does not exist.</exception>
public string ReadText(string path) => File.ReadAllText(path);
- // Reads text from a file asynchronously.
+ /// <summary>
+ /// Reads the contents of a file asynchronously as a string.
+ /// </summary>
+ /// <param name="path">The path to the file.</param>
+ /// <returns>A task representing the asynchronous operation, returning the contents of the file as a string.</returns>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="path"/> is null.</exception>
+ /// <exception cref="IOException">Thrown if an I/O error occurs while reading the file.</exception>
+ /// <exception cref="UnauthorizedAccessException">Thrown if access to the file is denied.</exception>
+ /// <exception cref="FileNotFoundException">Thrown if the file does not exist.</exception>
public async Task<string> ReadTextAsync(string path) => await File.ReadAllTextAsync(path);
- // Writes text to a file asynchronously.
+ /// <summary>
+ /// Writes the specified content to a file asynchronously.
+ /// </summary>
+ /// <param name="path">The path to the file.</param>
+ /// <param name="content">The content to write to the file.</param>
+ /// <returns>A task representing the asynchronous operation.</returns>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="path"/> or <paramref name="content"/> is null.</exception>
+ /// <exception cref="IOException">Thrown if an I/O error occurs while writing the file.</exception>
+ /// <exception cref="UnauthorizedAccessException">Thrown if access to the file is denied.</exception>
public async Task WriteTextAsync(string path, string content) => await File.WriteAllTextAsync(path, content);
- // Checks if a file exists.
+ /// <summary>
+ /// Checks if a file exists at the specified path.
+ /// </summary>
+ /// <param name="path">The path to the file.</param>
+ /// <returns><c>true</c> if the file exists; otherwise, <c>false</c>.</returns>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="path"/> is null.</exception>
public bool Exists(string path) => File.Exists(path);
}
diff --git a/JsonContextDb.JsonContext/JsonContext.cs b/JsonContextDb.JsonContext/JsonContext.cs
index 4bd6659..f1e24b1 100644
--- a/JsonContextDb.JsonContext/JsonContext.cs
+++ b/JsonContextDb.JsonContext/JsonContext.cs
@@ -1,4 +1,5 @@
using System.Collections;
+using System.Collections.Concurrent;
using System.Linq.Expressions;
using System.Security.Cryptography;
using System.Text.Json;
@@ -6,41 +7,39 @@ using System.Text.Json;
namespace JsonContextDb.JsonContext;
/// <summary>
-/// <license>Copyright (c) 2025 Alphons van der Heijden. All rights reserved.</license>
-/// Date: April 2025
/// A lightweight, JSON-based data context that emulates Entity Framework Core behavior.
-/// Provides methods to query and modify entities stored in JSON files, with thread-safe operations
-/// suitable for small datasets. Entities are managed in-memory and persisted to JSON files on disk.
+/// 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.
/// </summary>
/// <remarks>
-/// This class is designed to be used as a singleton to minimize file I/O, loading JSON files once at initialization.
+/// 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 during persistence. The context mimics EF Core's API, including <see cref="Set{T}"/>
-/// for querying and <see cref="SaveChangesAsync"/> for persisting changes. File operations are abstracted via
-/// <see cref="IFileStorage"/> for testability. Entities must have an integer <c>Id</c> property for identification.
+/// are asynchronous to avoid blocking. The context mimics EF Core's API, including <see cref="Set{T}"/> for querying
+/// and <see cref="SaveChangesAsync"/> for persisting changes. Entities must have an integer <c>Id</c> property.
+/// Copyright (c) 2025 Alphons van der Heijden. All rights reserved.
/// </remarks>
public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null, JsonContextOptions? options = null)
{
// Directory where JSON files are stored, required and validated.
private readonly string dataDirectory = dataDirectory ?? throw new ArgumentNullException(nameof(dataDirectory));
- // File storage implementation, defaults to FileSystemStorage if null.
+ // File storage implementation, abstracted via IFileStorage for testability and flexibility.
private readonly IFileStorage fileStorage = fileStorage ?? new FileSystemStorage();
// Configuration options for serialization and file naming.
private readonly JsonContextOptions jsonContextOptions = options ?? new JsonContextOptions();
// In-memory storage of entity lists, keyed by entity type.
- private readonly Dictionary<Type, IList> entityLists = [];
+ public readonly ConcurrentDictionary<Type, IList> 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.
+ // Stores serialized snapshots of entities for change tracking, using SHA-256 hashes to detect modifications.
private readonly Dictionary<object, byte[]> snapshots = [];
// Synchronization object for thread-safe operations.
- private readonly object lockObject = new();
+ public readonly object lockObject = new();
// Cache of compiled ID accessors for entities, improving performance.
private static readonly Dictionary<Type, Func<object, int>> IdAccessors = [];
@@ -48,92 +47,78 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
public enum ActionType
{
Add,
- Create,
Update,
- Delete,
Remove
}
- private byte[] ComputeSHA256Hash(object entity, JsonSerializerOptions options)
+ /// <summary>
+ /// Computes a SHA-256 hash of the serialized entity for change tracking.
+ /// </summary>
+ /// <param name="entity">The entity to hash.</param>
+ /// <param name="options">The JSON serialization options.</param>
+ /// <returns>A 32-byte hash of the serialized entity.</returns>
+ private static byte[] ComputeSHA256Hash(object entity, JsonSerializerOptions options)
{
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(entity, options);
return SHA256.HashData(jsonBytes); // 32 bytes output
}
- // Retrieves a queryable set of entities of type T, loading from file if not cached.
+ // Retrieves a queryable set of entities of type T
public JsonSet<T> Set<T>() where T : class
{
- lock (lockObject)
- {
- // Return cached entity list if already loaded.
- if (entityLists.TryGetValue(typeof(T), out IList? list) && list is List<T> convertedList)
- return new JsonSet<T>(this, convertedList);
- }
-
- // Load entities from JSON file.
- List<T> entities = LoadEntities<T>();
-
- lock (lockObject)
- {
- entityLists[typeof(T)] = entities;
- return new JsonSet<T>(this, entities);
- }
+ return new JsonSet<T>(this);
}
- // Asynchronous version of Set<T>, loading entities from file asynchronously.
- public async Task<JsonSet<T>> SetAsync<T>() where T : class
+ /// <summary>
+ /// Retrieves the <c>Id</c> property value from an entity.
+ /// </summary>
+ /// <param name="entity">The entity from which to retrieve the <c>Id</c>.</param>
+ /// <returns>The integer value of the <c>Id</c> property.</returns>
+ /// <exception cref="InvalidOperationException">Thrown when the entity lacks an <c>Id</c> property or the <c>Id</c> is null.</exception>
+ /// <remarks>
+ /// This method uses a cached accessor to avoid repeated reflection. It assumes all entities have an integer
+ /// <c>Id</c> property, which is required for matching entities in the in-memory list.
+ /// </remarks>
+ private static int GetId(object entity)
{
- lock (lockObject)
- {
- // Return cached entity list if already loaded.
- if (entityLists.TryGetValue(typeof(T), out IList? list) && list is List<T> convertedList)
- return new JsonSet<T>(this, convertedList);
- }
-
- // Load entities from JSON file asynchronously.
- List<T> entities = await LoadEntitiesAsync<T>();
-
- lock (lockObject)
+ var type = entity.GetType();
+ // Retrieve or create a cached accessor for the Id property.
+ if (!IdAccessors.TryGetValue(type, out var accessor))
{
- entityLists[typeof(T)] = entities;
- return new JsonSet<T>(this, entities);
+ 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;
}
- }
-
- private static int GetIdSafe(object entity)
- {
- var type = entity.GetType();
- var idProperty = type.GetProperty("Id") ?? throw new InvalidOperationException("Entity must have an Id property.");
- return (int?)idProperty.GetValue(entity) ?? 0;
+ return accessor(entity);
}
public class MetaData
{
- public int Id { get; set; } // Verplicht voor JsonContext
- public string EntityType { get; set; } = string.Empty; // Naam van het entiteitstype (bijv. "Customer")
- public int NextId { get; set; } // Volgende beschikbare ID voor dit type
+ public int Id { get; set; } // Required for JsonContext
+ public string EntityType { get; set; } = string.Empty; // Name of the entity type (e.g., "Customer")
+ public int NextId { get; set; } // Next available ID for this type
}
/// <summary>
- /// Haalt of maakt de MetaData voor een entiteitstype en retourneert de volgende beschikbare ID.
- /// Werkt de MetaData direct bij in de in-memory lijst.
+ /// Retrieves or creates the <see cref="MetaData"/> for an entity type and returns the next available ID.
+ /// Updates the <see cref="MetaData"/> directly in the in-memory list.
/// </summary>
- /// <returns>De volgende beschikbare ID voor het entiteitstype.</returns>
- private int GetNextIdForEntityType<T>() where T : class
+ /// <returns>The next available ID for the entity type.</returns>
+ /// <remarks>
+ /// This method is thread-safe, ensuring that ID generation is atomic and consistent across concurrent calls.
+ /// If no <see cref="MetaData"/> exists for the entity type, a new entry is created with an initial ID of 1.
+ /// </remarks>
+ private int GetNextIdForEntityType(Type t)
{
lock (lockObject)
{
- var typeKey = typeof(T).Name;
-
- List<MetaData>? metaDataList = null;
+ var typeKey = t.Name;
- if(!entityLists.ContainsKey(typeof(MetaData)))
- Set<MetaData>();
-
- metaDataList = entityLists[typeof(MetaData)] as List<MetaData>;
-
- if (metaDataList == null)
- throw new ArgumentNullException(nameof(metaDataList));
+ if (!entityLists.TryGetValue(typeof(MetaData), out var list) || list is not List<MetaData> metaDataList)
+ {
+ metaDataList = LoadEntities<MetaData>();
+ entityLists[typeof(MetaData)] = metaDataList;
+ }
var metaData = metaDataList.FirstOrDefault(m => m.EntityType == typeKey);
@@ -141,7 +126,7 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
{
metaData = new MetaData
{
- Id = metaDataList.Count>0 ? metaDataList.Max(m => m.Id) + 1 : 1,
+ Id = metaDataList.Count > 0 ? metaDataList.Max(m => m.Id) + 1 : 1,
EntityType = typeKey,
NextId = 1
};
@@ -163,18 +148,27 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
}
/// <summary>
- /// Marks an entity for addition to the data context. Only accessible via <see cref="JsonSet{T}"/>.
+ /// Marks an entity for addition to the data context, assigning a new ID if none is set.
/// </summary>
+ /// <param name="entity">The entity to add.</param>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="entity"/> is null.</exception>
+ /// <remarks>
+ /// This method is thread-safe, using a lock to ensure atomic operations. If the entity's <c>Id</c> is 0,
+ /// a new ID is generated using <see cref="GetNextIdForEntityType{T}"/>. A snapshot is created for change tracking.
+ /// Only accessible via <see cref="JsonSet{T}"/>.
+ /// </remarks>
internal void Add<T>(T entity) where T : class
{
ArgumentNullException.ThrowIfNull(entity);
lock (lockObject)
{
- var currentId = GetIdSafe(entity);
- if (currentId == 0) // Alleen toewijzen als ID niet is ingesteld
+ // Add to entityLists immediately
+ if (!entityLists.TryGetValue(typeof(T), out var list) || list is not List<T> typedList)
{
- SetId(entity, GetNextIdForEntityType<T>());
+ typedList = [];
+ entityLists[typeof(T)] = typedList;
}
+ typedList.Add(entity);
changes.Add((typeof(T), entity, ActionType.Add));
snapshots[entity] = ComputeSHA256Hash(entity, jsonContextOptions.SerializerOptions);
@@ -184,20 +178,23 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
/// <summary>
/// Marks multiple entities for addition to the data context. Only accessible via <see cref="JsonSet{T}"/>.
/// </summary>
+ /// <param name="entities">The entities to add.</param>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="entities"/> or any entity is null.</exception>
internal void AddRange<T>(IEnumerable<T> entities) where T : class
{
ArgumentNullException.ThrowIfNull(entities);
lock (lockObject)
{
+ if (!entityLists.TryGetValue(typeof(T), out var list) || list is not List<T> typedList)
+ {
+ typedList = [];
+ entityLists[typeof(T)] = typedList;
+ }
+
foreach (var entity in entities)
{
ArgumentNullException.ThrowIfNull(entity);
- var currentId = GetIdSafe(entity);
- if (currentId == 0)
- {
- SetId(entity, GetNextIdForEntityType<T>());
- }
-
+ typedList.Add(entity);
changes.Add((typeof(T), entity, ActionType.Add));
snapshots[entity] = ComputeSHA256Hash(entity, jsonContextOptions.SerializerOptions);
}
@@ -207,6 +204,8 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
/// <summary>
/// Marks an entity for update in the data context. Only accessible via <see cref="JsonSet{T}"/>.
/// </summary>
+ /// <param name="entity">The entity to update.</param>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="entity"/> is null.</exception>
internal void Update<T>(T entity) where T : class
{
ArgumentNullException.ThrowIfNull(entity);
@@ -234,6 +233,8 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
/// <summary>
/// Marks multiple entities for update in the data context. Only accessible via <see cref="JsonSet{T}"/>.
/// </summary>
+ /// <param name="entities">The entities to update.</param>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="entities"/> or any entity is null.</exception>
internal void UpdateRange<T>(IEnumerable<T> entities) where T : class
{
ArgumentNullException.ThrowIfNull(entities);
@@ -251,6 +252,8 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
/// <summary>
/// Marks an entity for removal from the data context. Only accessible via <see cref="JsonSet{T}"/>.
/// </summary>
+ /// <param name="entity">The entity to remove.</param>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="entity"/> is null.</exception>
internal void Remove<T>(T entity) where T : class
{
ArgumentNullException.ThrowIfNull(entity);
@@ -265,6 +268,8 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
/// <summary>
/// Marks multiple entities for removal from the data context. Only accessible via <see cref="JsonSet{T}"/>.
/// </summary>
+ /// <param name="entities">The entities to remove.</param>
+ /// <exception cref="ArgumentNullException">Thrown if <paramref name="entities"/> or any entity is null.</exception>
internal void RemoveRange<T>(IEnumerable<T> entities) where T : class
{
ArgumentNullException.ThrowIfNull(entities);
@@ -283,11 +288,12 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
/// <summary>
/// Asynchronously saves all pending changes to the JSON files and returns the number of affected entities.
/// </summary>
- /// <returns>A task that represents the asynchronous operation, with an integer indicating the number of entities
- /// successfully added, updated, or removed.</returns>
+ /// <returns>A task representing the asynchronous operation, returning the number of entities successfully added, updated, or removed.</returns>
/// <remarks>
- /// Applies pending changes to in-memory lists within a lock, then persists to JSON files asynchronously.
- /// If a file write fails, in-memory changes are rolled back to ensure consistency, and pending changes are preserved for retry.
+ /// 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.
/// </remarks>
public async Task<int> SaveChangesAsync()
{
@@ -307,7 +313,6 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
modifiedTypes = [.. changes.Select(c => c.Type)];
changesBackup = [.. changes];
- // Detecteer wijzigingen in alle entiteiten
foreach (var kvp in entityLists)
{
var type = kvp.Key;
@@ -319,7 +324,6 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
var current = ComputeSHA256Hash(entity, jsonContextOptions.SerializerOptions);
if (!snapshot.SequenceEqual(current) && !changes.Any(c => c.Entity == entity))
{
- // Entiteit is gewijzigd, markeer als Update
changes.Add((type, entity, ActionType.Update));
snapshots[entity] = current;
modifiedTypes.Add(type);
@@ -328,7 +332,6 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
}
}
- // Verwerk expliciete veranderingen (Add, Update, Remove)
foreach (var group in changes.GroupBy(c => c.Type))
{
var type = group.Key;
@@ -343,7 +346,9 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
{
if (change.Action == ActionType.Add)
{
- entities.Add(change.Entity);
+ SetId(change.Entity, GetNextIdForEntityType(type));
+
+ //entities.Add(change.Entity);
affectedEntities++;
}
else if (change.Action == ActionType.Update)
@@ -417,7 +422,7 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
/// is thrown. This method uses synchronous file I/O for simplicity and is called by <see cref="Set{T}"/> to initialize
/// the in-memory entity list.
/// </remarks>
- private List<T> LoadEntities<T>() where T : class
+ public List<T> LoadEntities<T>() where T : class
{
var jsonFilePath = Path.Combine(dataDirectory, jsonContextOptions.FileNameFactory(typeof(T)));
if (!fileStorage.Exists(jsonFilePath))
@@ -446,71 +451,7 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
}
}
- /// <summary>
- /// Loads the list of entities of type <typeparamref name="T"/> from the corresponding JSON file asynchronously.
- /// </summary>
- /// <typeparam name="T">The type of entity, which must be a class with an integer <c>Id</c> property.</typeparam>
- /// <returns>A task that represents the asynchronous operation, returning a <see cref="List{T}"/> containing the deserialized entities, or an empty list if the file does not exist.</ returns>
- /// <remarks>
- /// The JSON file is named using the <see cref="JsonContextOptions.FileNameFactory"/> 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 asynchronous file I/O and is called by <see cref="SetAsync{T}"/> to initialize
- /// the in-memory entity list.
- /// </remarks>
- private async Task<List<T>> LoadEntitiesAsync<T>() where T : class
- {
- // Construct the file path using the configured factory.
- var jsonFilePath = Path.Combine(dataDirectory, jsonContextOptions.FileNameFactory(typeof(T)));
- if (!fileStorage.Exists(jsonFilePath))
- {
- return [];
- }
-
- try
- {
- // Read and deserialize the JSON file asynchronously.
- var json = await fileStorage.ReadTextAsync(jsonFilePath);
- var entities = JsonSerializer.Deserialize<List<T>>(json, jsonContextOptions.SerializerOptions) ?? [];
-
- lock (lockObject)
- {
- // Store snapshots for change tracking.
- 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);
- }
- }
-
- /// <summary>
- /// Retrieves the <c>Id</c> property value from an entity.
- /// </summary>
- /// <param name="entity">The entity from which to retrieve the <c>Id</c>.</param>
- /// <returns>The integer value of the <c>Id</c> property.</returns>
- /// <exception cref="InvalidOperationException">Thrown when the entity lacks an <c>Id</c> property or the <c>Id</c> is null.</exception>
- /// <remarks>
- /// This method uses a cached accessor to avoid repeated reflection. It assumes all entities have an integer
- /// <c>Id</c> property, which is required for matching entities in the in-memory list.
- /// </remarks>
- 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);
- }
+
}
/// <summary>
@@ -518,32 +459,36 @@ public class JsonContext(string? dataDirectory, IFileStorage? fileStorage = null
/// in a JSON-based data context.
/// </summary>
/// <typeparam name="T">The type of entity, which must be a class with an integer <c>Id</c> property.</typeparam>
-public class JsonSet<T>(JsonContext context, List<T> entities) : IQueryable<T>, IQueryable, IEnumerable<T>, IEnumerable where T : class
+public class JsonSet<T>(JsonContext context) : IQueryable<T>, IQueryable, IEnumerable<T>, IEnumerable where T : class
{
// Reference to the parent JsonContext for operations.
private readonly JsonContext context = context ?? throw new ArgumentNullException(nameof(context));
// Queryable entity collection for LINQ operations.
- private readonly IQueryable<T> queryable = entities?.AsQueryable() ?? throw new ArgumentNullException(nameof(entities));
+ private IQueryable<T> GetQueryable()
+ {
+ lock (context.lockObject)
+ {
+ if (!context.entityLists.TryGetValue(typeof(T), out var list) || list is not List<T> typedList)
+ {
+ typedList = context.LoadEntities<T>();
+ context.entityLists[typeof(T)] = typedList;
+ }
+ return typedList.AsQueryable();
+ }
+ }
- // LINQ queryable properties.
- public Type ElementType => queryable.ElementType;
- public Expression Expression => queryable.Expression;
- public IQueryProvider Provider => queryable.Provider;
+ public Type ElementType => GetQueryable().ElementType;
+ public Expression Expression => GetQueryable().Expression;
+ public IQueryProvider Provider => GetQueryable().Provider;
- // Delegates add operations to the JsonContext.
public void Add(T entity) => context.Add(entity);
public void AddRange(IEnumerable<T> entities) => context.AddRange(entities);
-
- // Delegates update operations to the JsonContext.
public void Update(T entity) => context.Update(entity);
public void UpdateRange(IEnumerable<T> entities) => context.UpdateRange(entities);
-
- // Delegates remove operations to the JsonContext.
public void Remove(T entity) => context.Remove(entity);
public void RemoveRange(IEnumerable<T> entities) => context.RemoveRange(entities);
- // Provides enumeration over the queryable entities.
- public IEnumerator<T> GetEnumerator() => queryable.GetEnumerator();
+ public IEnumerator<T> GetEnumerator() => GetQueryable().GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
diff --git a/JsonContextDb.JsonContext/JsonContextOptions.cs b/JsonContextDb.JsonContext/JsonContextOptions.cs
index 261eb95..9b670e8 100644
--- a/JsonContextDb.JsonContext/JsonContextOptions.cs
+++ b/JsonContextDb.JsonContext/JsonContextOptions.cs
@@ -3,13 +3,36 @@
namespace JsonContextDb.JsonContext;
/// <summary>
-/// Configuration options for <see cref="JsonContext"/>.
+/// Configuration options for <see cref="JsonContext"/>, controlling JSON serialization and file naming.
/// </summary>
+/// <remarks>
+/// This class allows customization of how <see cref="JsonContext"/> serializes entities to JSON and names the output files.
+/// Instances of this class are typically provided during <see cref="JsonContext"/> initialization and should not be modified
+/// afterward to ensure consistent behavior. The default settings enable indented JSON output and generate file names based on
+/// the entity type name (e.g., "Customer.json" for a <c>Customer</c> type).
+/// </remarks>
public class JsonContextOptions
{
- // Configures JSON serialization options, enabling indented output by default.
+ /// <summary>
+ /// Gets or sets the JSON serialization options used for reading and writing entities.
+ /// </summary>
+ /// <remarks>
+ /// The default value is a <see cref="JsonSerializerOptions"/> instance with <c>WriteIndented</c> set to <c>true</c> for readable JSON output.
+ /// Customizing this property allows control over serialization behavior, such as case sensitivity or property naming.
+ /// This property must not be null when used by <see cref="JsonContext"/>.
+ /// </remarks>
+ /// <exception cref="ArgumentNullException">Thrown if set to null.</exception>
public JsonSerializerOptions SerializerOptions { get; set; } = new() { WriteIndented = true };
- // Factory function to generate file names based on entity type, defaulting to "{TypeName}.json".
+ /// <summary>
+ /// Gets or sets a factory function that generates file names based on entity types.
+ /// </summary>
+ /// <remarks>
+ /// The default factory generates file names in the format "{TypeName}.json" (e.g., "Customer.json" for a <c>Customer</c> type).
+ /// Customizing this function allows for alternative naming conventions, such as including subdirectories or different extensions.
+ /// The function must return a valid file name and should not return null or empty strings.
+ /// This property must not be null when used by <see cref="JsonContext"/>.
+ /// </remarks>
+ /// <exception cref="ArgumentNullException">Thrown if set to null.</exception>
public Func<Type, string> FileNameFactory { get; set; } = type => $"{type.Name}.json";
}
diff --git a/JsonContextDb.TestApp/Program.cs b/JsonContextDb.TestApp/Program.cs
index 56f6538..b91a1aa 100644
--- a/JsonContextDb.TestApp/Program.cs
+++ b/JsonContextDb.TestApp/Program.cs
@@ -7,32 +7,50 @@ var context = new JsonContext(Path.Combine(AppContext.BaseDirectory, "Data"));
var users = context.Set<User>();
var vsers = context.Set<Vser>();
-var sw = Stopwatch.StartNew();
-
-for (int i = 0; i < 1000; i++)
+if (!users.Any())
{
- users.Add(new User
- {
- Name = $"annet {i}"
- });
- vsers.Add(new Vser
+
+ for (int i = 1; i <= 100000; i++)
{
- Name = $"alphons {i}"
- });
+ users.Add(new User
+ {
+ Name = $"annet {i}"
+ });
+ vsers.Add(new Vser
+ {
+ Name = $"alphons {i}"
+ });
+ }
+ await context.SaveChangesAsync();
}
+var sw = Stopwatch.StartNew();
+
+var user = users.FirstOrDefault(x => x.Name.Contains("99998"));
+
+if (user != null)
+ Console.WriteLine($"{user.Name} - {user.Id} {sw.ElapsedMilliseconds} mS");
+else
+ Console.WriteLine("not found");
+
+sw = Stopwatch.StartNew();
-await Task.Delay(1000);
+user = users.FirstOrDefault(x => x.Name.Contains("Tester"));
+if (user != null)
+ Console.WriteLine($"{user.Name} - {user.Id} {sw.ElapsedMilliseconds} mS");
+else
+ Console.WriteLine("not found");
-await context.SaveChangesAsync();
+if(user != null)
+ user.Name = "Tester 1";
-var us = context.Set<User>();
+sw = Stopwatch.StartNew();
-var u = us.FirstOrDefault(x => x.Name.Contains("123"));
+var count = await context.SaveChangesAsync();
+Console.WriteLine($"Count:{count} {sw.ElapsedMilliseconds} mS");
-Console.WriteLine(u.Name + " " + u.Id + " " + sw.ElapsedMilliseconds + "mS");
Console.ReadLine();
class User