Skip to content

feat: replace Newtonsoft.Json with System.Text.Json#111

Open
ttu wants to merge 1 commit intomasterfrom
newtonsoft-to-system-text-json
Open

feat: replace Newtonsoft.Json with System.Text.Json#111
ttu wants to merge 1 commit intomasterfrom
newtonsoft-to-system-text-json

Conversation

@ttu
Copy link
Owner

@ttu ttu commented Nov 29, 2025

Implement #101

Summary by CodeRabbit

Release Notes

  • New Features

    • Constructor now accepts encryptionKey and minifyJson parameters for enhanced serialization control.
    • Improved handling of complex types, nested objects, and dynamic data structures.
    • Added comprehensive validation for JSON element serialization with mixed-type support.
  • Bug Fixes

    • Enhanced memory management with proper resource cleanup during data mutations.
  • Documentation

    • Updated migration guidance and compatibility notes for dynamic type handling.

✏️ Tip: You can customize this high-level summary in your review settings.

@ttu ttu force-pushed the newtonsoft-to-system-text-json branch 12 times, most recently from b51bb12 to 4c7dacf Compare December 4, 2025 16:38
@ttu ttu force-pushed the newtonsoft-to-system-text-json branch from 4c7dacf to 797709b Compare December 7, 2025 06:22
@ttu ttu force-pushed the newtonsoft-to-system-text-json branch from 797709b to fb7b991 Compare December 7, 2025 10:04
@coderabbitai
Copy link

coderabbitai bot commented Dec 15, 2025

Walkthrough

This pull request executes a major version upgrade (v3.0) that replaces Newtonsoft.Json with System.Text.Json across the entire library. The migration introduces new custom converters for ExpandoObject and DateTime handling, refactors the DataStore's internal JSON representation from JObject/JToken to JsonDocument/JsonElement, and updates all tests to use System.Text.Json APIs. Supporting infrastructure includes new helper methods for JSON navigation and mutation, synchronized access via dedicated locks, and comprehensive test updates reflecting the new serialization behavior.

Changes

Cohort / File(s) Summary
Core Data Store Migration
JsonFlatFileDataStore/DataStore.cs
Replaced JObject/JToken with JsonDocument/JsonElement; introduced JsonSerializerOptions for camelCase naming and case-insensitivity; added helper methods (TryGetElement, Parse, ConvertJsonElementToObject, SetJsonDataElement, RemoveJsonDataElement, GetChildren, GetJsonPath, FindPath); refactored Insert/Replace/Update/Delete to work with JsonElement; added _jsonDataLock for synchronization; updated constructor to accept encryptionKey and minifyJson; added resource disposal for JsonDocument.
Core Data Store Migration
JsonFlatFileDataStore/CommitActionHandler.cs
Replaced JObject.Parse with JsonDocument; updated HandleAction signature to accept JsonElement instead of JObject; modified control flow to use JsonElement root element clone.
Core Data Store Migration
JsonFlatFileDataStore/DocumentCollection.cs
Replaced JsonConvert with JsonSerializer using SystemExpandoObjectConverter; updated GetFieldValue for case-insensitive property lookup; added explicit Int32 handling in GetNextIdValue.
Core Data Store Migration
JsonFlatFileDataStore/ObjectExtensions.cs
Replaced Newtonsoft.Json with System.Text.Json; updated CopyProperties to deserialize via JsonSerializer with SystemExpandoObjectConverter; added JsonNode support in AddDataToField; implemented case-insensitive property mapping with existingKey resolution across Expando, Dictionary, and Array handling paths.
New Converters
JsonFlatFileDataStore/ExpandoObjectConverter.cs
New public class implementing JsonConverter for System.Text.Json with recursive handling for nested objects/arrays; converts JSON values to CLR types (int, long, double, decimal, bool, null, string, DateTime); includes TryParseDateTime helper for backward compatibility; provides error handling for invalid JSON.
New Converters
JsonFlatFileDataStore/NewtonsoftDateTimeConverter.cs
New public class implementing JsonConverter with multi-format parsing (ISO 8601 with timezone, without timezone, and fallback to DateTime.TryParse); writes DateTime in standardized ISO 8601 format.
Supporting Changes
JsonFlatFileDataStore/GlobalUsings.cs, JsonFlatFileDataStore/IDataStore.cs, JsonFlatFileDataStore/IDocumentCollection.cs
Added global using for System.Text.Json; added using System.Dynamic to interfaces; minor formatting adjustments.
Supporting Changes
JsonFlatFileDataStore/JsonFlatFileDataStore.csproj
Removed Newtonsoft.Json package reference; added System.Text.Json (v10.0.0) package reference; minor formatting adjustments to XML elements.
Test Updates - Collection Operations
JsonFlatFileDataStore.Test/CollectionModificationTests.cs, JsonFlatFileDataStore.Test/CollectionQueryTests.cs, JsonFlatFileDataStore.Test/CopyPropertiesTests.cs
Replaced JToken/JObject with JsonNode; updated JSON literals from single to double quotes; replaced JToken indexing with JsonNode.GetValue() and ToString() methods; updated using directives to System.Text.Json and SystemExpandoObjectConverter.
Test Updates - Data Store Tests
JsonFlatFileDataStore.Test/DataStoreTests.cs
Replaced JToken.Parse/JObject.Parse with JsonNode.Parse; updated JSON literals to double quotes; adjusted assertions for JsonNode indexer behavior with GetValue() calls; maintained test logic flow with new JSON APIs.
Test Updates - Additional Coverage
JsonFlatFileDataStore.Test/SingleItemTests.cs, JsonFlatFileDataStore.Test/DataStoreDisposeTests.cs, JsonFlatFileDataStore.Test/FileContentTests.cs
Expanded GetItem\_DynamicAndTyped\_DateType with additional assertions; added new test InsertItem\_ComplexTypes\_VerifiesJsonElementSerialization for complex JSON serialization validation; added VerifyJsonDocumentDisposal\_NoMemoryLeaks test; expanded AllFormats\_CorrectLength test data to include per-serializer and per-OS length expectations with detailed inline comments.
Documentation
README.md
Added "Major Version Changes" section noting v3.0 upgrade to System.Text.Json; replaced C# Language Version section with System.Text.Json vs Newtonsoft.Json subsection including migration notes, backward-compatibility adjustments, performance caveats, and code samples for dynamic/typed data handling.
Benchmark
JsonFlatFileDataStore.Benchmark/Program.cs
Minor indentation adjustment for BenchmarkSwitcher array; no functional changes.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Areas requiring extra attention:

  • DataStore.cs — Largest file affected with extensive refactoring; review new helper methods (TryGetElement, Parse, ConvertJsonElementToObject, SetJsonDataElement, RemoveJsonDataElement, GetChildren, GetJsonPath, FindPath) for correctness, edge cases, and proper resource disposal of JsonDocument instances
  • ObjectExtensions.cs — Complex case-insensitive property mapping logic across multiple handler methods (HandleExpando, HandleExpandoEnumerable, HandleExpandoObject, HandleExpandoDictionary); verify existingKey resolution preserves destination casing and correctly adds new keys
  • ExpandoObjectConverter.cs — New public converter with recursive nested object/array handling and DateTime parsing heuristics; verify error handling, type conversions, and recursion termination for deeply nested structures
  • NewtonsoftDateTimeConverter.cs — Custom DateTime format parsing with multiple fallback attempts; verify format compatibility and that parsing doesn't inadvertently accept invalid dates
  • Test coverage — Verify that all test modifications correctly validate the new System.Text.Json behavior, particularly assertions using GetValue() and edge cases involving dynamic/typed data, nested structures, and DateTime handling

Poem

🐰 From Newtonsoft to Text.Json we bound,
With JsonElements dancing all around,
New converters born, DateTime fits just right,
Case-insensitive lookups shining bright,
Version three hops forward, refactored with care! 🌟

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.90% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: replace Newtonsoft.Json with System.Text.Json' directly and concisely describes the primary change across all files in the pull request.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch newtonsoft-to-system-text-json

Comment @coderabbitai help to get the list of available commands and usage tips.

@ttu ttu marked this pull request as ready for review December 15, 2025 16:55
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
JsonFlatFileDataStore/DataStore.cs (1)

144-150: Race condition: unprotected SetJsonData call in GetItem methods.

When _reloadBeforeGetCollection is true, SetJsonData is called without lock protection, creating a TOCTOU window. The background commit thread (CommitActionHandler) can modify _jsonData between the reload and the subsequent read, causing inconsistent state or exceptions.

Wrap the reload and read operations with _jsonDataLock:

  public T GetItem<T>(string key)
  {
      if (_reloadBeforeGetCollection)
      {
-         SetJsonData(GetJsonObjectFromFile());
+         lock (_jsonDataLock)
+         {
+             SetJsonData(GetJsonObjectFromFile());
+         }
      }
      
      var convertedKey = _convertPathToCorrectCamelCase(key);
-     var token = TryGetElement(_jsonData.RootElement, convertedKey);
+     lock (_jsonDataLock)
+     {
+         var token = TryGetElement(_jsonData.RootElement, convertedKey);
+     }

Apply the same fix to GetItem(string key) at lines 169–185.

Also applies to: 169-175

🧹 Nitpick comments (9)
JsonFlatFileDataStore/DocumentCollection.cs (1)

419-433: Consider caching JsonSerializerOptions to avoid repeated allocations.

Creating JsonSerializerOptions on every GetFieldValue call is inefficient, especially since this method is called frequently during collection operations (inserts, updates, ID generation). The options object is identical each time and can be safely cached as a static field.

+    private static readonly JsonSerializerOptions _fieldValueOptions = new JsonSerializerOptions
+    {
+        Converters = { new SystemExpandoObjectConverter() },
+        PropertyNameCaseInsensitive = true
+    };
+
     private dynamic GetFieldValue(T item, string fieldName)
     {
-        var options = new JsonSerializerOptions
-        {
-            Converters = { new SystemExpandoObjectConverter() },
-            PropertyNameCaseInsensitive = true // Optional: make property name matching case-insensitive
-        };
-
-        var expando = JsonSerializer.Deserialize<ExpandoObject>(JsonSerializer.Serialize(item), options);
+        var expando = JsonSerializer.Deserialize<ExpandoObject>(JsonSerializer.Serialize(item, _fieldValueOptions), _fieldValueOptions);
         // Problem here is if we have typed data with upper camel case properties but lower camel case in JSON, so need to use OrdinalIgnoreCase string comparer
         var expandoAsIgnoreCase = new Dictionary<string, dynamic>(expando, StringComparer.OrdinalIgnoreCase);
JsonFlatFileDataStore.Test/CollectionQueryTests.cs (1)

140-150: Minor inconsistency in JsonNode value extraction.

Line 142 uses ToString() while line 149 uses GetValue<string>() for the same purpose (extracting a string value from JsonNode). Both work, but GetValue<string>() is more explicit and consistent with the GetValue<int>() call on line 150.

     var nextUpdate = JsonNode.Parse("{ \"myId\": \"somethingWrong2\" }");
     collection.InsertOne(nextUpdate);
-    Assert.Equal(nextId, nextUpdate["myId"].ToString());
+    Assert.Equal(nextId, nextUpdate["myId"].GetValue<string>());
JsonFlatFileDataStore/NewtonsoftDateTimeConverter.cs (2)

27-27: Potential null reference when date string is null.

reader.GetString() can return null if the JSON contains an explicit null value (though TokenType would be Null, not String in that case). However, if the string is empty or whitespace, dateString will be non-null but fail all parsing attempts, reaching line 60. Consider using the already-fetched dateString in the exception message instead of calling GetString() again.

-        throw new JsonException($"Invalid date format: {reader.GetString()}");
+        throw new JsonException($"Invalid date format: {dateString}");

30-37: Consider making formats array static for better performance.

The formats array is constant and recreated on every Read call. Moving it to a private static readonly field avoids repeated allocations.

 public class NewtonsoftDateTimeConverter : JsonConverter<DateTime>
 {
     private readonly string _defaultFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.FFFFFFFK"; // ISO 8601 format
+    
+    private static readonly string[] _formats = new[]
+    {
+        "yyyy-MM-ddTHH:mm:ss.FFFFFFFK",  // Full precision with timezone
+        "yyyy-MM-ddTHH:mm:ss.FFFFFFF",   // Full precision without timezone
+        "yyyy-MM-ddTHH:mm:ssK",          // Seconds with timezone
+        "yyyy-MM-ddTHH:mm:ss",           // Seconds without timezone (Newtonsoft default)
+        "yyyy-MM-dd"                     // Date only
+    };

     public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
     {
         if (reader.TokenType == JsonTokenType.String)
         {
             string dateString = reader.GetString();

-            // Common formats to try
-            string[] formats = new[]
-            {
-            "yyyy-MM-ddTHH:mm:ss.FFFFFFFK",  // Full precision with timezone
-            "yyyy-MM-ddTHH:mm:ss.FFFFFFF",   // Full precision without timezone
-            "yyyy-MM-ddTHH:mm:ssK",          // Seconds with timezone
-            "yyyy-MM-ddTHH:mm:ss",           // Seconds without timezone (Newtonsoft default)
-            "yyyy-MM-dd"                     // Date only
-        };
-
             // Try parsing with various formats
-            foreach (var format in formats)
+            foreach (var format in _formats)
README.md (1)

26-28: Consider using proper heading syntax for consistency.

Static analysis flags "Emphasis used instead of a heading" at line 26. Using a proper heading (## or setext style) would be more consistent with the rest of the document.

-**Major Version Changes**
+## Major Version Changes
JsonFlatFileDataStore/ExpandoObjectConverter.cs (1)

94-224: Consider extracting shared value conversion logic to reduce duplication.

The value conversion logic (handling Number, String, True, False, Null, Object) is duplicated across object properties (lines 54-92), array elements (lines 98-149), and nested array elements (lines 156-215). Extracting a shared helper like ConvertJsonElement(JsonElement element) would improve maintainability.

Example helper approach:

private static object ConvertJsonElement(JsonElement element)
{
    return element.ValueKind switch
    {
        JsonValueKind.Null or JsonValueKind.Undefined => null,
        JsonValueKind.True => true,
        JsonValueKind.False => false,
        JsonValueKind.Number => GetNumber(element),
        JsonValueKind.String => TryParseDateTime(element.GetString()),
        JsonValueKind.Object => ConvertToExpando(element),
        JsonValueKind.Array => ConvertToList(element),
        _ => null
    };
}
JsonFlatFileDataStore/DataStore.cs (3)

53-63: _toJsonFunc roundtrip may be inefficient.

The useLowerCamelCase=true path deserializes JsonElementExpandoObject → re-serializes with _serializerOptions. This roundtrip is expensive. Verify that it's necessary (e.g., for case-insensitive property handling via ObjectExtensions.CopyProperties) or consider a more direct serialization path that avoids the intermediate deserialization.


475-501: DateTime parsing heuristic in SingleDynamicItemReadConverter is fragile.

The character-based heuristic (length ≥ 8, starts with digit, contains separators) may misidentify strings or fail on valid ISO dates without separators. Additionally, repeated DateTime.TryParse() calls are expensive for non-date strings.

Since NewtonsoftDateTimeConverter is registered in _options (line 23), verify whether this manual parsing is necessary or if the converter already handles all required formats. If this code exists to handle an edge case, document why it's needed.


313-319: Extract IsCollection and IsItem predicates to class-level private methods.

These locally-defined predicates are clear but would benefit from extraction for reusability and testability. If the same collection-vs-item detection is needed elsewhere, this avoids duplication.

private bool IsCollection(JsonElement property) =>
    property.ValueKind == JsonValueKind.Array &&
    (!property.EnumerateArray().Any() || property.EnumerateArray().First().ValueKind == JsonValueKind.Object);

private bool IsItem(JsonElement property) =>
    property.ValueKind != JsonValueKind.Array ||
    (property.EnumerateArray().Any() && property.EnumerateArray().First().ValueKind != JsonValueKind.Object);

Then simplify GetKeys to use this.IsCollection(property.Value) and this.IsItem(property.Value).

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d58da7e and fb7b991.

📒 Files selected for processing (19)
  • JsonFlatFileDataStore.Benchmark/Program.cs (1 hunks)
  • JsonFlatFileDataStore.Test/CollectionModificationTests.cs (13 hunks)
  • JsonFlatFileDataStore.Test/CollectionQueryTests.cs (2 hunks)
  • JsonFlatFileDataStore.Test/CopyPropertiesTests.cs (7 hunks)
  • JsonFlatFileDataStore.Test/DataStoreDisposeTests.cs (1 hunks)
  • JsonFlatFileDataStore.Test/DataStoreTests.cs (10 hunks)
  • JsonFlatFileDataStore.Test/FileContentTests.cs (2 hunks)
  • JsonFlatFileDataStore.Test/SingleItemTests.cs (2 hunks)
  • JsonFlatFileDataStore/CommitActionHandler.cs (1 hunks)
  • JsonFlatFileDataStore/DataStore.cs (16 hunks)
  • JsonFlatFileDataStore/DocumentCollection.cs (1 hunks)
  • JsonFlatFileDataStore/ExpandoObjectConverter.cs (1 hunks)
  • JsonFlatFileDataStore/GlobalUsings.cs (1 hunks)
  • JsonFlatFileDataStore/IDataStore.cs (1 hunks)
  • JsonFlatFileDataStore/IDocumentCollection.cs (1 hunks)
  • JsonFlatFileDataStore/JsonFlatFileDataStore.csproj (2 hunks)
  • JsonFlatFileDataStore/NewtonsoftDateTimeConverter.cs (1 hunks)
  • JsonFlatFileDataStore/ObjectExtensions.cs (6 hunks)
  • README.md (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (6)
JsonFlatFileDataStore.Test/CollectionQueryTests.cs (3)
JsonFlatFileDataStore/DocumentCollection.cs (3)
  • ReplaceOne (102-129)
  • ReplaceOne (131-131)
  • InsertOne (38-50)
JsonFlatFileDataStore/IDocumentCollection.cs (3)
  • ReplaceOne (74-74)
  • ReplaceOne (83-83)
  • InsertOne (44-44)
JsonFlatFileDataStore/ObjectExtensions.cs (1)
  • GetValue (448-451)
JsonFlatFileDataStore/CommitActionHandler.cs (1)
JsonFlatFileDataStore/DataStore.cs (3)
  • JsonDocument (529-533)
  • JsonDocument (547-550)
  • JsonDocument (579-593)
JsonFlatFileDataStore/NewtonsoftDateTimeConverter.cs (1)
JsonFlatFileDataStore/ExpandoObjectConverter.cs (1)
  • Write (32-35)
JsonFlatFileDataStore/DocumentCollection.cs (3)
JsonFlatFileDataStore/DataStore.cs (4)
  • dynamic (169-185)
  • dynamic (457-516)
  • T (144-167)
  • T (552-565)
JsonFlatFileDataStore/ObjectExtensions.cs (3)
  • dynamic (131-142)
  • dynamic (144-154)
  • dynamic (489-492)
JsonFlatFileDataStore/ExpandoObjectConverter.cs (2)
  • SystemExpandoObjectConverter (7-283)
  • ExpandoObject (9-30)
JsonFlatFileDataStore.Test/SingleItemTests.cs (2)
JsonFlatFileDataStore/DataStore.cs (2)
  • InsertItem (187-187)
  • DeleteItem (265-265)
JsonFlatFileDataStore/IDataStore.cs (2)
  • InsertItem (71-71)
  • DeleteItem (123-123)
JsonFlatFileDataStore.Test/CopyPropertiesTests.cs (2)
JsonFlatFileDataStore/ExpandoObjectConverter.cs (2)
  • SystemExpandoObjectConverter (7-283)
  • ExpandoObject (9-30)
JsonFlatFileDataStore/DataStore.cs (2)
  • dynamic (169-185)
  • dynamic (457-516)
🪛 LanguageTool
README.md

[style] ~799-~799: Consider using “incompatible” to avoid wordiness.
Context: ...eatures require runtime support and are not compatible with .NET Standard 2.0. ## API API is...

(NOT_ABLE_PREMIUM)

🪛 markdownlint-cli2 (0.18.1)
README.md

26-26: Emphasis used instead of a heading

(MD036, no-emphasis-as-heading)


678-678: Heading style
Expected: setext; Actual: atx

(MD003, heading-style)


688-688: Heading style
Expected: setext; Actual: atx

(MD003, heading-style)


690-690: Heading style
Expected: setext; Actual: atx

(MD003, heading-style)


713-713: Heading style
Expected: setext; Actual: atx

(MD003, heading-style)


723-723: Heading style
Expected: setext; Actual: atx

(MD003, heading-style)


752-752: Heading style
Expected: setext; Actual: atx

(MD003, heading-style)


773-773: Heading style
Expected: setext; Actual: atx

(MD003, heading-style)


797-797: Heading style
Expected: setext; Actual: atx

(MD003, heading-style)

🔇 Additional comments (31)
JsonFlatFileDataStore.Benchmark/Program.cs (1)

9-12: LGTM!

Formatting adjustment with no functional changes.

JsonFlatFileDataStore.Test/DataStoreDisposeTests.cs (1)

77-142: Well-structured resource management test.

The test effectively validates that JsonDocument instances are properly managed throughout insert/read/delete operations, ensuring no premature disposal causes ObjectDisposedException. The multiple GC cycles help expose any lifecycle issues.

Note: While the test name mentions "NoMemoryLeaks", it primarily validates disposal correctness rather than measuring actual memory consumption.

JsonFlatFileDataStore.Test/FileContentTests.cs (1)

26-53: Excellent documentation of serialization differences.

The updated test data and detailed comments clearly explain the byte-length variations between Newtonsoft.Json and System.Text.Json across different formatting options and operating systems. This makes the test expectations explicit and maintainable.

JsonFlatFileDataStore/GlobalUsings.cs (1)

1-2: LGTM!

Adding the global using for System.Text.Json aligns with the migration strategy and will simplify JSON-related code throughout the project.

JsonFlatFileDataStore/IDocumentCollection.cs (1)

2-2: LGTM!

The using directive is appropriate for the interface's existing dynamic type usage (e.g., GetNextIdValue(), UpdateOne() parameters).

JsonFlatFileDataStore/IDataStore.cs (1)

2-2: LGTM!

The using directive appropriately supports the interface's existing dynamic type usage throughout its method signatures.

JsonFlatFileDataStore/CommitActionHandler.cs (1)

44-46: Proper JsonDocument lifecycle management.

The migration correctly uses JsonDocument.Parse with a using statement and clones the RootElement before passing it to HandleAction. The Clone() call is essential here because it creates an independent JsonElement that survives the disposal of the parent JsonDocument at the end of the using block.

JsonFlatFileDataStore/JsonFlatFileDataStore.csproj (1)

35-35: System.Text.Json version 10.0.0 is valid and available on NuGet. No known security vulnerabilities identified for this version.

JsonFlatFileDataStore.Test/CopyPropertiesTests.cs (4)

4-5: LGTM!

Import changes correctly reflect the migration from Newtonsoft.Json to System.Text.Json.


253-261: LGTM!

The serialization/deserialization pattern using SystemExpandoObjectConverter is the correct approach for converting JSON to ExpandoObject with System.Text.Json, maintaining compatibility with the previous Newtonsoft.Json behavior.


286-289: LGTM!

Good documentation of the xUnit 2.9.3+ workaround for ambiguity errors with dynamic types. The explicit (object) cast is the correct solution.


322-330: LGTM!

Consistent serialization pattern with SystemExpandoObjectConverter for typed object property copying.

JsonFlatFileDataStore/DocumentCollection.cs (1)

408-414: Good addition of Int32 handling for System.Text.Json compatibility.

System.Text.Json returns Int32 for smaller integers that fit within the range, unlike Newtonsoft.Json which often returned Int64. This explicit check ensures correct ID incrementing behavior.

However, note that the order of checks matters: checking Int64 first (line 408) before Int32 (line 411) is fine since C# won't implicitly box an Int32 as Int64 for dynamic comparison.

JsonFlatFileDataStore.Test/SingleItemTests.cs (2)

78-94: LGTM!

Excellent test coverage for DateTime handling. The tests verify that both typed and dynamic retrieval correctly parse date strings to DateTime, maintaining backward compatibility with Newtonsoft.Json behavior. The comments clearly document the expected behavior.


421-501: LGTM!

Comprehensive test that validates the System.Text.Json migration for complex data types. Good coverage of:

  • Nested objects with arrays and dictionaries
  • Simple arrays
  • Mixed type objects (strings, ints, doubles, booleans, nulls, dates)
  • Deletion operations to verify RemoveJsonDataElement works correctly

The test structure follows the existing patterns in the file and properly cleans up test artifacts.

JsonFlatFileDataStore.Test/DataStoreTests.cs (3)

3-7: LGTM!

Using directives correctly updated to bring in System.Text.Json and System.Text.Json.Nodes namespaces for the migration.


21-21: LGTM!

JSON string updated to use double quotes as required by the JSON specification. System.Text.Json is stricter than Newtonsoft.Json and requires valid JSON syntax.


316-322: LGTM!

Good documentation explaining the API difference between System.Text.Json's JsonNode (requires explicit GetValue<T>()) and Newtonsoft.Json's JToken (had implicit conversion operators). This helps future maintainers understand why the explicit calls are necessary.

JsonFlatFileDataStore.Test/CollectionQueryTests.cs (1)

1-1: LGTM!

Using directive correctly added for System.Text.Json.Nodes to support JsonNode usage.

JsonFlatFileDataStore/NewtonsoftDateTimeConverter.cs (1)

63-67: LGTM!

The Write method correctly serializes DateTime values using the ISO 8601 format with full precision, maintaining consistency with the documented behavior.

README.md (1)

678-782: Comprehensive migration documentation.

The migration notes clearly document the differences between Newtonsoft.Json and System.Text.Json, with practical code examples for common scenarios. The performance considerations and backward compatibility notes are particularly helpful for users upgrading from v2.x.

JsonFlatFileDataStore/ObjectExtensions.cs (4)

23-31: Serialize-then-deserialize pattern for JsonNode conversion.

The approach of serializing and deserializing to convert JsonNode to ExpandoObject is correct and necessary given the API differences. The PropertyNameCaseInsensitive = true option ensures consistency with the case-insensitive handling throughout.


41-48: LGTM on JsonNode handling.

The JsonObject handling correctly uses JsonValue.Create(data) to set field values dynamically. This aligns with System.Text.Json patterns.


299-315: Case-insensitive property matching correctly implemented.

The implementation properly maintains backward compatibility with Newtonsoft.Json's case-insensitive property matching. The pattern of finding existing keys and preserving original casing is correct.


320-348: LGTM on HandleExpandoEnumerable updates.

The case-insensitive key handling is consistent with the pattern used in HandleExpando. The null check and fallback to srcProp.Name ensures new properties are added correctly.

JsonFlatFileDataStore.Test/CollectionModificationTests.cs (3)

1-4: Test imports updated for System.Text.Json migration.

The using statements correctly reference System.Text.Json and System.Text.Json.Nodes for the migration.


455-461: JsonNode parsing and iteration correctly migrated.

The test properly uses JsonNode.Parse() followed by .AsArray() and .Select() to iterate over JSON array elements, correctly replacing the Newtonsoft.Json pattern.


887-895: Test demonstrates correct SystemExpandoObjectConverter usage.

The test properly uses the new SystemExpandoObjectConverter pattern documented in the README for creating patch ExpandoObject instances from dictionaries.

JsonFlatFileDataStore/ExpandoObjectConverter.cs (2)

9-30: Read method correctly handles JsonDocument lifecycle.

The using statement ensures proper disposal of JsonDocument, and the implementation correctly builds an ExpandoObject from the JSON structure.


258-282: Well-documented performance tradeoff for DateTime parsing.

The TryParseDateTime helper clearly documents the performance implications and the reasoning for maintaining Newtonsoft.Json compatibility. The suggestion to use strongly-typed collections for performance-critical scenarios is helpful.

JsonFlatFileDataStore/DataStore.cs (1)

519-526: Resource management for JsonDocument is well-handled.

The use of using statements (lines 571, 605), explicit disposal in SetJsonData (line 525), and cleanup in Dispose (line 119) properly manage JsonDocument lifetime and prevent resource leaks. The Clone() pattern for JsonElement is correct and necessary given the struct lifetime constraints.

Also applies to: 571-577, 605-609

Comment on lines +239 to 260
(bool, JsonElement) UpdateAction()
{
if (_jsonData[convertedKey] == null)
return (false, _jsonData);
var data = TryGetElement(_jsonData.RootElement, convertedKey);
if (data == null)
return (false, _jsonData.RootElement);

var toUpdate = SingleDynamicItemReadConverter(_jsonData[convertedKey]);
var toUpdate = SingleDynamicItemReadConverter(data.Value);

if (ObjectExtensions.IsReferenceType(item) && ObjectExtensions.IsReferenceType(toUpdate))
{
ObjectExtensions.CopyProperties(item, toUpdate);
_jsonData[convertedKey] = JToken.FromObject(toUpdate);
var newElement = ConvertToJsonElement(toUpdate);
_jsonData = SetJsonDataElement(_jsonData.RootElement, convertedKey, newElement);
}
else
{
_jsonData[convertedKey] = JToken.FromObject(item);
var newElement = ConvertToJsonElement(item);
_jsonData = SetJsonDataElement(_jsonData.RootElement, convertedKey, newElement);
}

return (true, _jsonData);
return (true, _jsonData.RootElement);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Find the DataStore.cs file and examine CommitActionHandler and lock usage
find . -name "DataStore.cs" -type f

Repository: ttu/json-flatfile-datastore

Length of output: 107


🏁 Script executed:

# Search for CommitActionHandler class definition and HandleAction
rg -B 3 -A 20 "class CommitActionHandler" --type cs

Repository: ttu/json-flatfile-datastore

Length of output: 1941


🏁 Script executed:

# Search for _jsonDataLock usage patterns
rg "_jsonDataLock|lock.*_jsonData" --type cs -B 2 -A 2

Repository: ttu/json-flatfile-datastore

Length of output: 2050


🏁 Script executed:

# Look at UpdateAction to see where it's called from
rg -B 15 "UpdateAction\(\)" --type cs | head -80

Repository: ttu/json-flatfile-datastore

Length of output: 4597


🏁 Script executed:

# Find CommitItem method definition
rg -B 5 -A 30 "CommitItem\(" --type cs | head -100

Repository: ttu/json-flatfile-datastore

Length of output: 7657


🏁 Script executed:

# Look for where the UpdateAction callback is actually executed
rg -B 10 -A 10 "CommitAction\|HandleAction" --type cs | head -150

Repository: ttu/json-flatfile-datastore

Length of output: 53


🏁 Script executed:

# Examine full DataStore.cs context around CommitItem and how actions are invoked
cat -n JsonFlatFileDataStore/DataStore.cs | sed -n '160,200p'

Repository: ttu/json-flatfile-datastore

Length of output: 1601


🏁 Script executed:

# Look for where the action is invoked from CommitActionHandler
rg "action.Action\|\.Invoke\|action\.Callback" --type cs -B 3 -A 3

Repository: ttu/json-flatfile-datastore

Length of output: 53


🏁 Script executed:

# Find CommitItem method definition more directly
rg -B 2 -A 40 "private.*Task.*CommitItem" --type cs

Repository: ttu/json-flatfile-datastore

Length of output: 3078


🏁 Script executed:

# Look for where the action/callback is actually executed in DataStore
rg -B 5 -A 15 "CommitAction|action\(\)" --type cs | head -120

Repository: ttu/json-flatfile-datastore

Length of output: 8610


🏁 Script executed:

# Check SetJsonData method
rg -B 2 -A 8 "private.*SetJsonData\(" --type cs

Repository: ttu/json-flatfile-datastore

Length of output: 919


🏁 Script executed:

# Verify GetItem doesn't use lock
rg -B 3 -A 8 "public dynamic GetItem" --type cs

Repository: ttu/json-flatfile-datastore

Length of output: 879


🏁 Script executed:

# Verify the exact line numbers for lines 251 and 256 in Update method
sed -n '239,260p' JsonFlatFileDataStore/DataStore.cs | cat -n

Repository: ttu/json-flatfile-datastore

Length of output: 1131


🏁 Script executed:

# Get full CommitActionHandler implementation to see if any locks are used
cat JsonFlatFileDataStore/CommitActionHandler.cs

Repository: ttu/json-flatfile-datastore

Length of output: 2374


Verify lock semantics for _jsonData assignment in Update method.

Lines 251 and 256 directly assign _jsonData without explicit _jsonDataLock protection. The UpdateAction callback executes in CommitActionHandler.HandleStoreCommitActions on a background thread, where concurrent reads from GetItem() are not serialized. Other code paths that modify _jsonData use explicit lock (_jsonDataLock) guards (e.g., UpdateAll, Reload, constructor), making this a thread-safety inconsistency. Confirm whether the unprotected write-read access is intentional or if _jsonDataLock is required here.

🤖 Prompt for AI Agents
In JsonFlatFileDataStore/DataStore.cs around lines 239 to 260, UpdateAction
assigns to the shared field _jsonData without using the existing _jsonDataLock,
causing a thread-safety inconsistency with other writers; fix by acquiring
lock(_jsonDataLock) around any read-modify-write access to _jsonData inside
UpdateAction (i.e., wrap the TryGetElement/read of RootElement and the
subsequent SetJsonDataElement assignment in the same lock) so readers and other
writers remain synchronized, or alternatively ensure CommitActionHandler invokes
UpdateAction while already holding the same lock — pick one approach and apply
it consistently.

Comment on lines +579 to +593
private JsonDocument SetJsonDataElement(JsonElement original, string key, object item)
{
// Convert _jsonData to a Dictionary to make modifications
var jsonDataDict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(original.GetRawText());

// Convert the item to JsonElement
var newElement = ConvertToJsonElement(item);

// Set or update the element in the dictionary
jsonDataDict[key] = newElement;

// Serialize back to JsonElement
var modifiedJson = JsonSerializer.Serialize(jsonDataDict);
return JsonDocument.Parse(modifiedJson);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine the DataStore.cs file
fd -e cs | grep -i datastore

Repository: ttu/json-flatfile-datastore

Length of output: 1410


🏁 Script executed:

# Look at the file structure
ls -la JsonFlatFileDataStore/

Repository: ttu/json-flatfile-datastore

Length of output: 1166


🏁 Script executed:

# Examine lines 579-609 of DataStore.cs
cat -n JsonFlatFileDataStore/DataStore.cs | sed -n '575,615p'

Repository: ttu/json-flatfile-datastore

Length of output: 2056


🌐 Web query:

JsonElement deserialization Dictionary .NET safety orphaned references buffer

💡 Result:

Short answer

  • JsonElement/JsonDocument expose views over an underlying UTF-8 buffer — they do NOT own a deep copy. If that buffer or the JsonDocument is disposed or reused (e.g., returned from a using or returned while the source buffer is pooled), the JsonElement becomes invalid (or “orphaned”). [1][2]

What to do (safe options)

  • Deserialize directly into the target Dictionary (JsonSerializer.Deserialize<Dictionary<string,T>>) so you never hold JsonElement references. This is the simplest and safest approach. [2][3]
  • If you must keep a JsonElement past the JsonDocument lifetime, call JsonElement.Clone() (or clone the RootElement). Clone produces a standalone copy safe to store. [1]
  • When reading from a pooled buffer / Utf8JsonReader, prefer APIs that copy the data for you (JsonSerializer.Deserialize overloads that accept reader/span) or take care not to return elements that reference a rented buffer. [2]

Extra notes

  • Avoid using Dictionary<TKey, TValue> with non-string keys unless you know the risks; System.Text.Json has limitations and deserializing arbitrary key types can be unsafe. [4]

Sources
[1] StackOverflow: JsonElement / JsonDocument lifetime and JsonElement.Clone. (stackoverflow.com)
[2] Microsoft docs: JsonSerializer.Deserialize overloads and notes about reader/span copying. (docs.microsoft.com)
[3] Microsoft docs: Deserialization guidance for System.Text.Json. (learn.microsoft.com)
[4] Microsoft: Migrate from Newtonsoft.Json — warning about Dictionary key types and security. (learn.microsoft.com)

If you want, paste the code path you’re using (JsonDocument.Parse/ParseAsync, Utf8JsonReader, JsonSerializer, etc.) and I’ll give exact code snippets to make it safe.

Citations:


🌐 Web query:

JsonSerializer.Deserialize Dictionary<string, JsonElement> C# safety issues

💡 Result:

Short answer — common safety / correctness issues when calling JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(...) and how to mitigate them:

  1. Deep / large JSON (DoS / stack/recursion limits, high memory). System.Text.Json enforces a max-depth and can throw or consume lots of memory for huge payloads; configure and validate MaxDepth/streaming or reject oversized input. [1][2][3]

  2. Retained buffers / lifetime / allocations. JsonElement values come from parsed DOM structures (JsonDocument/parse path) and can hold references to underlying buffers if you misuse APIs (avoid returning JsonDocument.RootElement from a transient document; prefer JsonElement.Parse or streaming deserialization for large/long‑lived data). Use streaming (Utf8JsonReader) when possible. [2]

  3. Unexpected types / polymorphism risk (security). While System.Text.Json does not use Newtonsoft's TypeNameHandling by default, deserializing into loosely typed containers (Dictionary<TKey, TValue> where TKey or TValue is not constrained) can allow attackers to send unexpected data shapes that your code later interprets unsafely. Treat untrusted JSON as untrusted input and validate contents before acting on them. Microsoft documents a caution about Dictionary key types and security. [1][4]

  4. Converters / behavior differences vs Newtonsoft. System.Text.Json will materialize untyped object-like values as JsonElement — code that assumed Newtonsoft behavior may mis-handle types (e.g., expecting raw CLR types). Test code paths that enumerate or convert JsonElement values. [2][5]

  5. Trimming/AOT and reflection issues. Calling parameterless Deserialize(string) can require reflection metadata that influences AOT/trimming; prefer source-generated JsonTypeInfo/JsonSerializerContext or supply JsonSerializerOptions in constrained environments. [2]

Mitigations (practical):

  • Validate input size and depth up-front; set/verify JsonReaderOptions/JsonSerializerOptions.MaxDepth and reject overly deep or large payloads. [3]
  • Use streaming APIs (Utf8JsonReader / Deserialize with reader) for large or untrusted payloads to avoid whole-DOM allocation. [2][6]
  • If you need DOM but not to hold buffers, use JsonElement.Parse or create a JsonDocument you control and keep its lifetime explicit; don’t return RootElement from a disposed document. [2]
  • Validate keys and values explicitly (types, allowed properties, lengths) before using them; prefer strongly typed DTOs where possible. [1][4]
  • For AOT/trimming scenarios, use source-generated contexts or explicit JsonTypeInfo to avoid missing converters at runtime. [2]

Sources:

  • Microsoft docs — “Migrate from Newtonsoft.Json to System.Text.Json” (caution about Dictionary key types). [1]
  • Microsoft API docs & .NET blog (JsonSerializer.Deserialize overloads, JsonElement/JsonDocument lifetime, guidance to not return JsonDocument.RootElement). [2]
  • .NET runtime / GitHub and community notes on MaxDepth and denial‑of‑service considerations. [3]
  • .NET code‑analysis guidance about insecure deserialization (Newtonsoft example; general principle to restrict types and validate input). [4]

If you want, I can: (a) show example safe code (streaming + validation), or (b) review a short snippet of your current deserialization code and point out concrete fixes.


Unsafe JsonElement deserialization in SetJsonDataElement and RemoveJsonDataElement.

Deserializing to Dictionary<string, JsonElement> creates JsonElement instances that reference a temporary JsonDocument's buffer. When this document is garbage-collected, the JsonElement references become invalid, causing exceptions or data corruption during re-serialization at lines 591 and 604.

Replace the deserialization type with Dictionary<string, object>:

  private JsonDocument SetJsonDataElement(JsonElement original, string key, object item)
  {
-     var jsonDataDict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(original.GetRawText());
+     var jsonDataDict = JsonSerializer.Deserialize<Dictionary<string, object>>(original.GetRawText());
      
      var newElement = ConvertToJsonElement(item);
      jsonDataDict[key] = newElement;
      
      var modifiedJson = JsonSerializer.Serialize(jsonDataDict);
      return JsonDocument.Parse(modifiedJson);
  }
  
  public (bool, JsonElement) RemoveJsonDataElement(JsonElement original, string key)
  {
-     var jsonDataDict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(original.GetRawText());
+     var jsonDataDict = JsonSerializer.Deserialize<Dictionary<string, object>>(original.GetRawText());
      
      var removed = jsonDataDict.Remove(key);
      
      var modifiedJson = JsonSerializer.Serialize(jsonDataDict);
      using var jsonDocument = JsonDocument.Parse(modifiedJson);
      
      return (removed, jsonDocument.RootElement.Clone());
  }

Also applies to: 595-609

Comment on lines +595 to +609
public (bool, JsonElement) RemoveJsonDataElement(JsonElement original, string key)
{
// Deserialize _jsonData to a dictionary for modification
var jsonDataDict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(original.GetRawText());

// Remove the specified key
var removed = jsonDataDict.Remove(key);

// Serialize the updated dictionary back to a JsonElement
var modifiedJson = JsonSerializer.Serialize(jsonDataDict);
using var jsonDocument = JsonDocument.Parse(modifiedJson);

// Clone the root element so it doesn't reference the disposed JsonDocument
return (removed, jsonDocument.RootElement.Clone());
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unexpected public visibility on RemoveJsonDataElement helper.

RemoveJsonDataElement is marked public but only called internally from Delete (line 275). Consider making it private to avoid accidental public API exposure and clarify internal-only intent.

-    public (bool, JsonElement) RemoveJsonDataElement(JsonElement original, string key)
+    private (bool, JsonElement) RemoveJsonDataElement(JsonElement original, string key)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public (bool, JsonElement) RemoveJsonDataElement(JsonElement original, string key)
{
// Deserialize _jsonData to a dictionary for modification
var jsonDataDict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(original.GetRawText());
// Remove the specified key
var removed = jsonDataDict.Remove(key);
// Serialize the updated dictionary back to a JsonElement
var modifiedJson = JsonSerializer.Serialize(jsonDataDict);
using var jsonDocument = JsonDocument.Parse(modifiedJson);
// Clone the root element so it doesn't reference the disposed JsonDocument
return (removed, jsonDocument.RootElement.Clone());
}
private (bool, JsonElement) RemoveJsonDataElement(JsonElement original, string key)
{
// Deserialize _jsonData to a dictionary for modification
var jsonDataDict = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(original.GetRawText());
// Remove the specified key
var removed = jsonDataDict.Remove(key);
// Serialize the updated dictionary back to a JsonElement
var modifiedJson = JsonSerializer.Serialize(jsonDataDict);
using var jsonDocument = JsonDocument.Parse(modifiedJson);
// Clone the root element so it doesn't reference the disposed JsonDocument
return (removed, jsonDocument.RootElement.Clone());
}
🤖 Prompt for AI Agents
In JsonFlatFileDataStore/DataStore.cs around lines 595 to 609, the helper
RemoveJsonDataElement is declared public but is only used internally (called
from Delete at line 275); change its visibility to private to prevent exposing
it in the public API and clarify intent, ensuring any references (such as the
call in Delete) remain valid and run unit tests/compile to confirm no external
usages depend on it.

Comment on lines +249 to +252
else
{
arrayValues.Add(element.ToString());
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Type information lost for primitive values in deeply nested arrays.

element.ToString() converts all primitive values (numbers, booleans) to strings, which differs from the type-preserving behavior in AddPropertyToExpando. This could cause unexpected behavior when accessing deeply nested array values (e.g., 3 becomes "3").

Consider handling all value kinds consistently:

             else
             {
-                arrayValues.Add(element.ToString());
+                switch (element.ValueKind)
+                {
+                    case JsonValueKind.Number:
+                        if (element.TryGetInt32(out int intVal)) arrayValues.Add(intVal);
+                        else if (element.TryGetInt64(out long longVal)) arrayValues.Add(longVal);
+                        else if (element.TryGetDouble(out double doubleVal)) arrayValues.Add(doubleVal);
+                        else arrayValues.Add(element.GetDecimal());
+                        break;
+                    case JsonValueKind.True:
+                        arrayValues.Add(true);
+                        break;
+                    case JsonValueKind.False:
+                        arrayValues.Add(false);
+                        break;
+                    case JsonValueKind.String:
+                        arrayValues.Add(TryParseDateTime(element.GetString()));
+                        break;
+                    default:
+                        arrayValues.Add(null);
+                        break;
+                }
             }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In JsonFlatFileDataStore/ExpandoObjectConverter.cs around lines 249-252, the
code unconditionally calls element.ToString() when adding deeply nested array
primitives, losing original types (numbers, booleans, null). Replace that
ToString() call with type-aware handling consistent with AddPropertyToExpando:
inspect element.Type (or use element.Type switch / JTokenType) and for
Objects/Arrays recurse into the same conversion logic, for Strings use
element.Value<string>(), for Integers/Floats use
element.Value<long>/Value<double>/ToObject<decimal>() as appropriate (or
element.ToObject<object>() if you prefer a generic numeric), for Boolean use
element.Value<bool>(), and for Null add null; then add the preserved-typed value
to arrayValues.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant