Browse Source

Project JSON objects into script as shape-mode objects

JsonMapper built every JSON object it projects into script as an instance
of a private ObjectInstance subclass that existed only to be instantiable.
A host subclass can never carry the engine's shape-mode storage flag, so
each of those objects - all of ctx.data's leaf objects, and every JsonValue
var - sat permanently outside the own-property inline caches, and a script
reading the same property across a batch of content items re-resolved it
every time.

JsObject.CreateFromEntries builds the same object through the hidden class
machinery instead: repeated calls presenting the same key sequence, which
every content item of one schema does, share an interned hidden class, so
those reads stay monomorphic. The result is documented as indistinguishable
from the equivalent object literal - same own key order, same
configurable/enumerable/writable data properties - and anything the
representation cannot express (a digit-leading key, a very wide object)
falls back to the ordinary dictionary representation rather than to
different behaviour.

That fallback is silent, which is why the shaping is asserted rather than
assumed: Engine.Advanced.HasSharedShape is the supported predicate for it,
and JsonMapperTests pins that the projected object and its nested objects
answer true. Building them as a host subclass again would fail that test.

Three smaller fixes in the same file:

- the reverse direction allocated a string key per array element
  (a.Get(i.ToString(...))); the indexed accessor reads the dense backing
  directly and keeps the prototype walk for a modified array;
- JsNumber.Create reuses cached instances for small integers where
  new JsNumber always allocated;
- JsString.Create, public since 4.15.3, interns the empty and single
  character strings where new JsString always allocated.

Pinned by the projection tests added first - own key order, JSON.stringify,
for..in, mutation including delete and add, and the round trip back to
JsonValue - which pass before and after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f
pull/1326/head
Marko Lahma 1 month ago
parent
commit
11f7c9ab98
  1. 31
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JsonMapper.cs
  2. 53
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JsonMapperTests.cs

31
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<string, JsValue>[obj.Count];
var index = 0;
foreach (var (key, value) in obj)
{
target.Set(key, Map(value, engine));
entries[index++] = new KeyValuePair<string, JsValue>(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;

53
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))));
}
}
Loading…
Cancel
Save