Skip to content

Commit bf6cb1b

Browse files
authored
fix: support null handling in nested structures (#508)
1 parent a515540 commit bf6cb1b

File tree

6 files changed

+359
-8
lines changed

6 files changed

+359
-8
lines changed

datapipelines/src/main/kotlin/io/customer/datapipelines/extensions/JsonExtensions.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,16 @@ private fun Any?.toSerializableJson(): JsonElement {
4343
else -> this.toJsonElement()
4444
}
4545
}
46+
47+
/**
48+
* Recursively replaces all `null` values in nested structure with [JsonNull],
49+
* preserving structure and type. Useful for safely converting to `JsonElement`
50+
* using `Any.toJsonElement()` extension.
51+
*/
52+
@Suppress("UNCHECKED_CAST")
53+
internal fun <T> T?.sanitizeNullsForJson(): T = when (this) {
54+
null -> JsonNull as T
55+
is Map<*, *> -> this.mapValues { (_, v) -> v.sanitizeNullsForJson() } as T
56+
is List<*> -> this.map { it.sanitizeNullsForJson() } as T
57+
else -> this
58+
}

datapipelines/src/main/kotlin/io/customer/sdk/CustomerIO.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import io.customer.base.internal.InternalCustomerIOApi
1313
import io.customer.datapipelines.config.DataPipelinesModuleConfig
1414
import io.customer.datapipelines.di.analyticsFactory
1515
import io.customer.datapipelines.extensions.asMap
16+
import io.customer.datapipelines.extensions.sanitizeNullsForJson
1617
import io.customer.datapipelines.extensions.type
1718
import io.customer.datapipelines.extensions.updateAnalyticsConfig
1819
import io.customer.datapipelines.migration.TrackingMigrationProcessor
@@ -186,8 +187,8 @@ class CustomerIO private constructor(
186187
if (identifier != null) {
187188
identify(userId = identifier, traits = value)
188189
} else {
189-
logger.debug("No user profile found, updating traits for anonymous user ${analytics.anonymousId()}")
190-
analytics.identify(traits = value)
190+
logger.debug("No user profile found, updating sanitized traits for anonymous user ${analytics.anonymousId()}")
191+
analytics.identify(traits = value.sanitizeNullsForJson())
191192
}
192193
}
193194

datapipelines/src/main/kotlin/io/customer/sdk/DataPipelineInstance.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.customer.sdk
22

33
import com.segment.analytics.kotlin.core.emptyJsonObject
44
import com.segment.analytics.kotlin.core.utilities.JsonAnySerializer
5+
import io.customer.datapipelines.extensions.sanitizeNullsForJson
56
import io.customer.sdk.data.model.CustomAttributes
67
import io.customer.sdk.events.TrackMetric
78
import kotlinx.serialization.SerializationStrategy
@@ -64,9 +65,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
6465
* [Learn more](https://customer.io/docs/api/#operation/identify)
6566
* @param traits Map of <String, Any> to be added
6667
*/
67-
fun identify(userId: String, traits: CustomAttributes) {
68+
fun identify(userId: String, traits: Map<String, Any?>) {
6869
// Method needed for Java interop as inline doesn't work with Java
69-
identify(userId = userId, traits = traits, serializationStrategy = JsonAnySerializer.serializersModule.serializer())
70+
identify(userId = userId, traits = traits.sanitizeNullsForJson(), serializationStrategy = JsonAnySerializer.serializersModule.serializer())
7071
}
7172

7273
/**
@@ -128,9 +129,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
128129
* @param properties Map of <String, Any> to be added
129130
* @see [Learn more](https://customer.io/docs/cdp/sources/source-spec/track-spec/)
130131
*/
131-
fun track(name: String, properties: CustomAttributes) {
132+
fun track(name: String, properties: Map<String, Any?>) {
132133
// Method needed for Java interop as inline doesn't work with Java
133-
track(name = name, properties = properties, serializationStrategy = JsonAnySerializer.serializersModule.serializer())
134+
track(name = name, properties = properties.sanitizeNullsForJson(), serializationStrategy = JsonAnySerializer.serializersModule.serializer())
134135
}
135136

136137
/**
@@ -201,9 +202,9 @@ abstract class DataPipelineInstance : CustomerIOInstance {
201202
* @param properties Additional details about the screen in Map <String, Any> format.
202203
* @see [Learn more](https://customer.io/docs/cdp/sources/source-spec/screen-spec/)
203204
*/
204-
fun screen(title: String, properties: CustomAttributes) {
205+
fun screen(title: String, properties: Map<String, Any?>) {
205206
// Method needed for Java interop as inline doesn't work with Java
206-
screen(title = title, properties = properties, serializationStrategy = JsonAnySerializer.serializersModule.serializer())
207+
screen(title = title, properties = properties.sanitizeNullsForJson(), serializationStrategy = JsonAnySerializer.serializersModule.serializer())
207208
}
208209

209210
/**

datapipelines/src/test/java/io/customer/datapipelines/DataPipelinesInteractionTests.kt

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,4 +658,204 @@ class DataPipelinesInteractionTests : JUnitTest() {
658658
}
659659

660660
//endregion
661+
662+
//region Nested Null Handling Tests
663+
664+
@Test
665+
fun track_givenNestedNullInCustomAttributes_expectSuccessfulEventProcessing() {
666+
val givenEvent = String.random
667+
val givenProperties = mapOf(
668+
"level1" to mapOf(
669+
"key1" to "value1",
670+
"key2" to null,
671+
"nested" to mapOf(
672+
"deepKey" to "deepValue",
673+
"nullValue" to null
674+
)
675+
),
676+
"nullObject" to null,
677+
"regularValue" to "test"
678+
)
679+
680+
sdkInstance.track(givenEvent, givenProperties)
681+
682+
outputReaderPlugin.trackEvents.size shouldBeEqualTo 1
683+
val trackEvent = outputReaderPlugin.trackEvents.lastOrNull()
684+
trackEvent.shouldNotBeNull()
685+
686+
trackEvent.event shouldBeEqualTo givenEvent
687+
688+
// Verify event was processed successfully and properties match
689+
// The outputReaderPlugin should have the properties with JsonNull replacing nulls
690+
val level1Map = trackEvent.properties["level1"]
691+
level1Map.shouldNotBeNull()
692+
693+
// Ensure we can access the properties without errors, which would happen if nulls weren't properly handled
694+
val nestedMap = (level1Map as Map<*, *>)["nested"]
695+
nestedMap.shouldNotBeNull()
696+
}
697+
698+
@Test
699+
fun track_givenNullInDeepNestedStructure_expectSuccessfulEventProcessing() {
700+
val givenEvent = String.random
701+
val userData: CustomAttributes = mapOf(
702+
"name" to "John Doe",
703+
"contact" to mapOf(
704+
"email" to "john@example.com",
705+
"phone" to null, // Null phone
706+
"address" to mapOf(
707+
"street" to "123 Main St",
708+
"city" to null, // Null city
709+
"zipCode" to "12345"
710+
)
711+
),
712+
"preferences" to mapOf(
713+
"notifications" to mapOf(
714+
"email" to true,
715+
"push" to null, // Null push preference
716+
"frequency" to mapOf(
717+
"daily" to true,
718+
"weekly" to null // Nested null
719+
)
720+
),
721+
"theme" to null // Null theme
722+
)
723+
)
724+
725+
sdkInstance.track(givenEvent, userData)
726+
727+
outputReaderPlugin.trackEvents.size shouldBeEqualTo 1
728+
val trackEvent = outputReaderPlugin.trackEvents.lastOrNull()
729+
trackEvent.shouldNotBeNull()
730+
731+
trackEvent.event shouldBeEqualTo givenEvent
732+
733+
// Verify the nested structure with nulls was processed correctly
734+
val contact = trackEvent.properties["contact"] as? Map<*, *>
735+
contact.shouldNotBeNull()
736+
737+
val address = contact["address"] as? Map<*, *>
738+
address.shouldNotBeNull()
739+
740+
val preferences = trackEvent.properties["preferences"] as? Map<*, *>
741+
preferences.shouldNotBeNull()
742+
743+
val notifications = preferences["notifications"] as? Map<*, *>
744+
notifications.shouldNotBeNull()
745+
746+
val frequency = notifications["frequency"] as? Map<*, *>
747+
frequency.shouldNotBeNull()
748+
}
749+
750+
@Test
751+
fun track_givenArrayWithNestedNulls_expectSuccessfulEventProcessing() {
752+
val givenEvent = String.random
753+
val givenProperties: CustomAttributes = mapOf(
754+
"items" to listOf(
755+
mapOf("id" to 1, "value" to "first", "optional" to null),
756+
mapOf("id" to 2, "value" to null, "details" to mapOf("key" to null)),
757+
null
758+
)
759+
)
760+
761+
sdkInstance.track(givenEvent, givenProperties)
762+
763+
outputReaderPlugin.trackEvents.size shouldBeEqualTo 1
764+
val trackEvent = outputReaderPlugin.trackEvents.lastOrNull()
765+
trackEvent.shouldNotBeNull()
766+
767+
trackEvent.event shouldBeEqualTo givenEvent
768+
769+
// Verify the event was processed successfully
770+
val items = trackEvent.properties["items"]
771+
items.shouldNotBeNull()
772+
(items as List<*>).size shouldBeEqualTo 3
773+
}
774+
775+
@Test
776+
fun identify_givenNestedNullInAttributes_expectSuccessfulProfileCreation() {
777+
val givenIdentifier = String.random
778+
val givenTraits: CustomAttributes = mapOf(
779+
"profile" to mapOf(
780+
"firstName" to "Jane",
781+
"lastName" to "Doe",
782+
"settings" to mapOf(
783+
"language" to "en",
784+
"timezone" to null,
785+
"preferences" to mapOf(
786+
"notifications" to true,
787+
"marketing" to null
788+
)
789+
),
790+
"company" to null
791+
)
792+
)
793+
794+
sdkInstance.identify(givenIdentifier, givenTraits)
795+
796+
analytics.userId() shouldBeEqualTo givenIdentifier
797+
798+
outputReaderPlugin.identifyEvents.size shouldBeEqualTo 1
799+
val identifyEvent = outputReaderPlugin.identifyEvents.lastOrNull()
800+
identifyEvent.shouldNotBeNull()
801+
802+
identifyEvent.userId shouldBeEqualTo givenIdentifier
803+
804+
// If this passes, it means the SDK correctly handled the nested nulls
805+
val profile = identifyEvent.traits["profile"] as? Map<*, *>
806+
profile.shouldNotBeNull()
807+
808+
val settings = profile["settings"] as? Map<*, *>
809+
settings.shouldNotBeNull()
810+
811+
val preferences = settings["preferences"] as? Map<*, *>
812+
preferences.shouldNotBeNull()
813+
}
814+
815+
@Test
816+
fun screen_givenNestedNullInProperties_expectSuccessfulScreenView() {
817+
val givenTitle = String.random
818+
val givenProperties: CustomAttributes = mapOf(
819+
"view" to mapOf(
820+
"id" to "home_screen",
821+
"elements" to listOf(
822+
mapOf("type" to "button", "visible" to true),
823+
mapOf("type" to "input", "value" to null),
824+
null
825+
),
826+
"metadata" to mapOf(
827+
"version" to "1.0",
828+
"debug" to null,
829+
"nested" to mapOf(
830+
"key" to "value",
831+
"optional" to null
832+
)
833+
)
834+
)
835+
)
836+
837+
sdkInstance.screen(givenTitle, givenProperties)
838+
839+
outputReaderPlugin.screenEvents.size shouldBeEqualTo 1
840+
val screenEvent = outputReaderPlugin.screenEvents.lastOrNull()
841+
screenEvent.shouldNotBeNull()
842+
843+
screenEvent.name shouldBeEqualTo givenTitle
844+
845+
// Verify the event properties were correctly processed with null handling
846+
val view = screenEvent.properties["view"] as? Map<*, *>
847+
view.shouldNotBeNull()
848+
849+
val elements = view["elements"] as? List<*>
850+
elements.shouldNotBeNull()
851+
elements.size shouldBeEqualTo 3
852+
853+
val metadata = view["metadata"] as? Map<*, *>
854+
metadata.shouldNotBeNull()
855+
856+
val nested = metadata["nested"] as? Map<*, *>
857+
nested.shouldNotBeNull()
858+
}
859+
860+
//endregion
661861
}

datapipelines/src/test/java/io/customer/datapipelines/plugins/CustomerIODestinationTest.kt

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import io.customer.datapipelines.testutils.core.JUnitTest
66
import io.customer.datapipelines.testutils.core.testConfiguration
77
import io.customer.datapipelines.testutils.utils.OutputReaderPlugin
88
import io.customer.datapipelines.testutils.utils.trackEvents
9+
import io.customer.sdk.data.model.CustomAttributes
910
import io.mockk.every
1011
import io.mockk.mockkConstructor
1112
import okio.IOException
1213
import org.amshove.kluent.shouldBe
14+
import org.amshove.kluent.shouldBeEqualTo
1315
import org.amshove.kluent.shouldNotBe
16+
import org.amshove.kluent.shouldNotBeNull
1417
import org.junit.jupiter.api.Test
1518

1619
/**
@@ -62,4 +65,69 @@ class CustomerIODestinationTest : JUnitTest() {
6265
integrations shouldNotBe null
6366
(integrations?.contains(CUSTOMER_IO_DATA_PIPELINES) ?: false) shouldBe true
6467
}
68+
69+
@Test
70+
fun givenComplexEventWithNestedNulls_expectSuccessfulEventProcessingThroughDestination() {
71+
// Create an event with complex nested structure containing nulls
72+
val eventName = "complex_event"
73+
val properties: CustomAttributes = mapOf(
74+
"customer" to mapOf(
75+
"id" to 123,
76+
"name" to "Test Customer",
77+
"account" to mapOf(
78+
"type" to "premium",
79+
"paymentMethod" to null, // Null at deeper level
80+
"details" to mapOf(
81+
"active" to true,
82+
"lastPaymentDate" to null // Another null at even deeper level
83+
)
84+
),
85+
"preferences" to null // Null at second level
86+
),
87+
"products" to listOf(
88+
mapOf("id" to 1, "name" to "Product A", "variants" to null), // Null in list item
89+
mapOf(
90+
"id" to 2,
91+
"name" to "Product B",
92+
"variants" to listOf(
93+
mapOf("color" to "red", "size" to null), // Null in nested list item
94+
null // Null list item in nested list
95+
)
96+
),
97+
null // Null list item
98+
)
99+
)
100+
101+
// Track event with complex properties containing nulls
102+
sdkInstance.track(eventName, properties)
103+
104+
// Verify the event was processed successfully
105+
outputReaderPlugin.trackEvents.size shouldBeEqualTo 1
106+
val event = outputReaderPlugin.trackEvents.first()
107+
event.shouldNotBeNull()
108+
event.event shouldBeEqualTo eventName
109+
110+
// Verify the nested structures were preserved
111+
val customer = event.properties["customer"] as? Map<*, *>
112+
customer.shouldNotBeNull()
113+
114+
val account = customer["account"] as? Map<*, *>
115+
account.shouldNotBeNull()
116+
117+
val details = account["details"] as? Map<*, *>
118+
details.shouldNotBeNull()
119+
120+
// Verify the list was properly processed
121+
val products = event.properties["products"] as? List<*>
122+
products.shouldNotBeNull()
123+
products.size shouldBeEqualTo 3
124+
125+
// Verify an item with a nested list was processed correctly
126+
val productB = products[1] as? Map<*, *>
127+
productB.shouldNotBeNull()
128+
129+
val variants = productB["variants"] as? List<*>
130+
variants.shouldNotBeNull()
131+
variants.size shouldBeEqualTo 2
132+
}
65133
}

0 commit comments

Comments
 (0)