From 1edd5f36e25755c54ea16eae526ed98d11aae922 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Mon, 10 Aug 2026 23:53:28 +0200 Subject: [PATCH] fix: restore outer RefFactory after nested JSON deserialization --- .../orm/jackson/spi/JsonORMConverterImpl.java | 12 +++- .../orm/jackson/JsonORMConverterImplTest.java | 46 +++++++++++++ .../orm/jackson/spi/JsonORMConverterImpl.java | 12 +++- .../JsonORMConverterIntegrationTest.java | 49 ++++++++++++++ .../serialization/spi/JsonORMConverterImpl.kt | 12 +++- .../JsonORMConverterIntegrationTest.kt | 65 +++++++++++++++++++ 6 files changed, 190 insertions(+), 6 deletions(-) diff --git a/storm-jackson2/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java b/storm-jackson2/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java index 15e763321..2a09fc61d 100644 --- a/storm-jackson2/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java +++ b/storm-jackson2/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java @@ -181,13 +181,21 @@ public Object fromDatabase(@Nonnull Object[] values, @Nonnull RefFactory refFact if (value == null) { return null; } + // A custom deserializer may issue a query, nesting another fromDatabase call on this thread. The previous + // factory is therefore restored rather than removed, so the remaining fields of the outer value still + // deserialize with the outer factory and produce attached refs. + RefFactory outerRefFactory = REF_FACTORY.get(); + REF_FACTORY.set(refFactory); try { - REF_FACTORY.set(refFactory); return mapper.readValue((String) values[0], typeReference); } catch (JsonProcessingException e) { throw new SqlTemplateException(e); } finally { - REF_FACTORY.remove(); + if (outerRefFactory == null) { + REF_FACTORY.remove(); + } else { + REF_FACTORY.set(outerRefFactory); + } } } } diff --git a/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterImplTest.java b/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterImplTest.java index 8069f22dd..74fb484ce 100644 --- a/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterImplTest.java +++ b/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterImplTest.java @@ -34,7 +34,9 @@ import st.orm.Json; import st.orm.PK; import st.orm.PersistenceException; +import st.orm.Ref; import st.orm.jackson.model.Address; +import st.orm.jackson.model.Owner; /** * Tests for {@link st.orm.jackson.spi.JsonORMConverterImpl} targeting uncovered branches: @@ -277,4 +279,48 @@ public void jsonWithFailOnMissingTrueShouldSucceedWithCompleteJson() { .getSingleResult(OwnerWithFailOnMissing.class); assertNotNull(result.address()); } + + // Nested deserialization: a custom deserializer that issues a query mid-deserialization. + + public static class NestedQueryMarkerDeserializer extends JsonDeserializer { + static DataSource nestedDataSource; + + @Override + public String deserialize(JsonParser parser, DeserializationContext context) + throws java.io.IOException { + String text = parser.getText(); + // Maps Owner, whose @Json address field enters fromDatabase re-entrantly on this thread. + of(nestedDataSource) + .query("SELECT id, first_name, last_name, address, telephone FROM owner WHERE id = 2") + .getSingleResult(Owner.class); + return text; + } + } + + public record OwnerSnapshot( + @JsonDeserialize(using = NestedQueryMarkerDeserializer.class) String marker, + Ref owner + ) {} + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithSnapshot( + @PK Integer id, + @Nonnull @Json OwnerSnapshot snapshot, + @Nullable String telephone + ) implements Entity {} + + @Test + public void refDeserializedAfterNestedConversionShouldRemainAttached() { + // The marker field deserializes first and issues a nested query, which binds and unbinds the nested + // conversion's RefFactory. The owner field that follows must still see the outer factory: the + // resulting ref is attached and fetches the owner. + NestedQueryMarkerDeserializer.nestedDataSource = dataSource; + var orm = of(dataSource); + var query = orm.query("SELECT id, JSON_OBJECT('marker' VALUE 'audit', 'owner' VALUE id) AS snapshot, telephone FROM owner WHERE id = 1"); + var result = query.getSingleResult(OwnerWithSnapshot.class); + var ownerRef = result.snapshot().owner(); + assertTrue(ownerRef.isFetchable()); + assertEquals("Betty", ownerRef.fetch().firstName()); + } } diff --git a/storm-jackson3/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java b/storm-jackson3/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java index 50c9823c9..78b61e79b 100644 --- a/storm-jackson3/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java +++ b/storm-jackson3/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java @@ -181,13 +181,21 @@ public Object fromDatabase(@Nonnull Object[] values, @Nonnull RefFactory refFact if (value == null) { return null; } + // A custom deserializer may issue a query, nesting another fromDatabase call on this thread. The previous + // factory is therefore restored rather than removed, so the remaining fields of the outer value still + // deserialize with the outer factory and produce attached refs. + RefFactory outerRefFactory = REF_FACTORY.get(); + REF_FACTORY.set(refFactory); try { - REF_FACTORY.set(refFactory); return mapper.readValue((String) values[0], typeReference); } catch (JacksonException e) { throw new SqlTemplateException(e); } finally { - REF_FACTORY.remove(); + if (outerRefFactory == null) { + REF_FACTORY.remove(); + } else { + REF_FACTORY.set(outerRefFactory); + } } } } diff --git a/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java b/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java index 3b5f70998..66b9d2e5d 100644 --- a/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java +++ b/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java @@ -454,4 +454,53 @@ public void polymorphicJsonWithExplicitTypeNamesShouldResolveSubtype() { assertEquals(10, owner.size()); assertTrue(owner.stream().allMatch(x -> x.person instanceof NamedPersonA)); } + + // Nested deserialization: a custom deserializer that issues a query mid-deserialization. + + public static class NestedQueryMarkerDeserializer extends tools.jackson.databind.deser.std.StdDeserializer { + static DataSource nestedDataSource; + + public NestedQueryMarkerDeserializer() { + super(String.class); + } + + @Override + public String deserialize(tools.jackson.core.JsonParser parser, + tools.jackson.databind.DeserializationContext ctxt) + throws tools.jackson.core.JacksonException { + String text = parser.getText(); + // Maps Owner, whose @Json address field enters fromDatabase re-entrantly on this thread. + of(nestedDataSource) + .query("SELECT id, first_name, last_name, address, telephone FROM owner WHERE id = 2") + .getSingleResult(Owner.class); + return text; + } + } + + public record OwnerSnapshot( + @tools.jackson.databind.annotation.JsonDeserialize(using = NestedQueryMarkerDeserializer.class) String marker, + Ref owner + ) {} + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithSnapshot( + @PK Integer id, + @Nonnull @Json OwnerSnapshot snapshot, + @Nullable String telephone + ) implements Entity {} + + @Test + public void refDeserializedAfterNestedConversionShouldRemainAttached() { + // The marker field deserializes first and issues a nested query, which binds and unbinds the nested + // conversion's RefFactory. The owner field that follows must still see the outer factory: the + // resulting ref is attached and fetches the owner. + NestedQueryMarkerDeserializer.nestedDataSource = dataSource; + var orm = of(dataSource); + var query = orm.query("SELECT id, JSON_OBJECT('marker' VALUE 'audit', 'owner' VALUE id) AS snapshot, telephone FROM owner WHERE id = 1"); + var result = query.getSingleResult(OwnerWithSnapshot.class); + var ownerRef = result.snapshot().owner(); + assertTrue(ownerRef.isFetchable()); + assertEquals("Betty", ownerRef.fetch().firstName()); + } } diff --git a/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/spi/JsonORMConverterImpl.kt b/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/spi/JsonORMConverterImpl.kt index 433fe0f0b..c270ff22b 100644 --- a/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/spi/JsonORMConverterImpl.kt +++ b/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/spi/JsonORMConverterImpl.kt @@ -185,13 +185,21 @@ internal class JsonORMConverterImpl( override fun fromDatabase(values: Array, refFactory: RefFactory): Any? { val raw = values[0] as? String? ?: return null + // A custom serializer may issue a query, nesting another fromDatabase call on this thread. The previous + // factory is therefore restored rather than removed, so the remaining fields of the outer value still + // deserialize with the outer factory and produce attached refs. + val outerRefFactory = REF_FACTORY.get() + REF_FACTORY.set(refFactory) return try { - REF_FACTORY.set(refFactory) this@JsonORMConverterImpl.json.decodeFromString(serializer, raw) } catch (e: SerializationException) { throw SqlTemplateException(e) } finally { - REF_FACTORY.remove() + if (outerRefFactory == null) { + REF_FACTORY.remove() + } else { + REF_FACTORY.set(outerRefFactory) + } } } } diff --git a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterIntegrationTest.kt b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterIntegrationTest.kt index 7c94b24b6..d5fd9caf4 100644 --- a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterIntegrationTest.kt +++ b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/JsonORMConverterIntegrationTest.kt @@ -1,7 +1,13 @@ package st.orm.serialization +import kotlinx.serialization.Contextual +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.json.JsonClassDiscriminator import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertNotNull @@ -452,4 +458,63 @@ open class JsonORMConverterIntegrationTest(@Autowired val dataSource: DataSource assertNotNull(result) assertEquals(listOf("a", "b", "c"), result.names) } + + // Nested deserialization: a custom serializer that issues a query mid-deserialization. + + object NestedQueryMarkerSerializer : KSerializer { + lateinit var nestedDataSource: DataSource + + override val descriptor = PrimitiveSerialDescriptor("NestedQueryMarker", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: String) = encoder.encodeString(value) + + override fun deserialize(decoder: Decoder): String { + val text = decoder.decodeString() + // Maps Owner, whose @Json address field enters fromDatabase re-entrantly on this thread. + ORMTemplate.of(nestedDataSource) + .query("SELECT id, first_name, last_name, address, telephone FROM owner WHERE id = 2") + .getSingleResult(Owner::class) + return text + } + } + + // The ref target must be @Serializable itself, since the compiler requires a serializer for the type + // argument of a @Contextual Ref property. Maps the owner table without its @Json address column. + @Serializable + @DbTable("owner") + data class SerializableOwner( + @PK val id: Int = 0, + val firstName: String, + val lastName: String, + val telephone: String?, + ) : Entity + + @Serializable + data class OwnerSnapshot( + @Serializable(with = NestedQueryMarkerSerializer::class) val marker: String, + @Contextual val owner: Ref, + ) + + @DbTable("owner") + data class OwnerWithSnapshot( + @PK val id: Int, + @Json val snapshot: OwnerSnapshot, + val telephone: String?, + ) : Entity + + @Test + fun `ref deserialized after nested conversion should remain attached`() { + // The marker field deserializes first and issues a nested query, which binds and unbinds the nested + // conversion's RefFactory. The owner field that follows must still see the outer factory: the + // resulting ref is attached and fetches the owner. + NestedQueryMarkerSerializer.nestedDataSource = dataSource + val orm = ORMTemplate.of(dataSource) + val query = orm.query( + "SELECT id, JSON_OBJECT('marker' VALUE 'audit', 'owner' VALUE id) AS snapshot, telephone FROM owner WHERE id = 1", + ) + val result = query.getSingleResult(OwnerWithSnapshot::class) + val ownerRef = result.snapshot.owner + Assertions.assertTrue(ownerRef.isFetchable) + assertEquals("Betty", ownerRef.fetch().firstName) + } }