diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs index 4fd3efdf8..3f0ed8ac1 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Collections; -using System.Globalization; using Jint; using Jint.Native; using Jint.Native.Object; @@ -18,10 +17,6 @@ namespace Squidex.Domain.Apps.Core.Scripting.Internal; public static class JsonMapper { - private sealed class JsonObjectInstance(Engine engine) : ObjectInstance(engine) - { - } - public static JsValue Map(JsonValue value, Engine engine) { switch (value.Value) @@ -33,9 +28,9 @@ public static class JsonMapper case false: return JsBoolean.False; case double n: - return new JsNumber(n); + return JsNumber.Create(n); case string s: - return new JsString(s); + return JsString.Create(s); case JsonObject o: return FromObject(o, engine); case JsonArray a: @@ -58,16 +53,20 @@ public static class JsonMapper return engine.Intrinsics.Array.Construct(target); } - private static JsonObjectInstance FromObject(JsonObject obj, Engine engine) + private static JsObject FromObject(JsonObject obj, Engine engine) { - var target = new JsonObjectInstance(engine); + // Built through the hidden class machinery, so JSON objects sharing a key sequence - every content + // item of the same schema does - share one hidden class and keep a script reading them monomorphic. + // A bare ObjectInstance subclass can never be in shape mode and is outside the read caches entirely. + var entries = new KeyValuePair[obj.Count]; + var index = 0; foreach (var (key, value) in obj) { - target.Set(key, Map(value, engine)); + entries[index++] = new KeyValuePair(key, Map(value, engine)); } - return target; + return JsObject.CreateFromEntries(engine, entries); } public static JsonValue Map(JsValue? value) @@ -116,11 +115,15 @@ public static class JsonMapper if (value is JsArray a) { - var result = new JsonArray((int)a.Length); + var length = a.Length; + + var result = new JsonArray((int)length); - for (var i = 0; i < a.Length; i++) + // The indexed accessor reads the dense backing directly, where a string key would allocate one + // key per element and route through the full property lookup. + for (var i = 0u; i < length; i++) { - result.Add(Map(a.Get(i.ToString(CultureInfo.InvariantCulture)))); + result.Add(Map(a[i])); } return result; diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs new file mode 100644 index 000000000..2872ebcd8 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs @@ -0,0 +1,53 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Jint; +using Jint.Native.Object; +using Squidex.Domain.Apps.Core.Scripting.Internal; +using Squidex.Infrastructure.Json.Objects; + +namespace Squidex.Domain.Apps.Core.Operations.Scripting; + +public class JsonMapperTests +{ + [Fact] + public void Should_map_json_objects_into_a_shared_shape() + { + var engine = new Engine(o => o.Strict()); + + var mapped = (ObjectInstance)JsonMapper.Map(CreateJson(), engine); + var nested = (ObjectInstance)mapped.Get("nested"); + + // A shared shape is what keeps a script reading a batch of content items monomorphic. It is a + // performance property and never a correctness one, but it is silent when it regresses: building + // these objects as a host ObjectInstance subclass again would put them back in the per-object + // dictionary with no test noticing. + Assert.True(engine.Advanced.HasSharedShape(mapped)); + Assert.True(engine.Advanced.HasSharedShape(nested)); + } + + [Fact] + public void Should_share_the_shape_between_objects_of_the_same_shape() + { + var engine = new Engine(o => o.Strict()); + + var first = (ObjectInstance)JsonMapper.Map(CreateJson(), engine); + var second = (ObjectInstance)JsonMapper.Map(CreateJson(), engine); + + Assert.True(engine.Advanced.HasSharedShape(first)); + Assert.True(engine.Advanced.HasSharedShape(second)); + } + + private static JsonValue CreateJson() + { + return JsonValue.Create( + new JsonObject() + .Add("name", JsonValue.Create("squidex")) + .Add("count", JsonValue.Create(3)) + .Add("nested", JsonValue.Create(new JsonObject().Add("flag", JsonValue.True)))); + } +}