@@ -136,8 +136,8 @@ func TestCacheAnalyticsCollector_FieldHashing(t *testing.T) {
136136 t .Run ("same input produces same hash" , func (t * testing.T ) {
137137 c := NewCacheAnalyticsCollector ()
138138
139- c .HashFieldValue ("User" , "name" , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
140- c .HashFieldValue ("User" , "name" , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
139+ c .HashFieldValue ("User" , "name" , nil , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
140+ c .HashFieldValue ("User" , "name" , nil , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
141141
142142 snap := c .Snapshot ()
143143 assert .Equal (t , 2 , len (snap .FieldHashes ))
@@ -151,8 +151,8 @@ func TestCacheAnalyticsCollector_FieldHashing(t *testing.T) {
151151 t .Run ("different input produces different hash" , func (t * testing.T ) {
152152 c := NewCacheAnalyticsCollector ()
153153
154- c .HashFieldValue ("User" , "name" , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
155- c .HashFieldValue ("User" , "name" , []byte (`"Bob"` ), `{"id":"2"}` , 0 , FieldSourceSubgraph , "" )
154+ c .HashFieldValue ("User" , "name" , nil , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
155+ c .HashFieldValue ("User" , "name" , nil , []byte (`"Bob"` ), `{"id":"2"}` , 0 , FieldSourceSubgraph , "" )
156156
157157 snap := c .Snapshot ()
158158 assert .Equal (t , 2 , len (snap .FieldHashes ))
@@ -162,7 +162,7 @@ func TestCacheAnalyticsCollector_FieldHashing(t *testing.T) {
162162 t .Run ("hashed keys mode" , func (t * testing.T ) {
163163 c := NewCacheAnalyticsCollector ()
164164
165- c .HashFieldValue ("User" , "name" , []byte (`"Alice"` ), "" , 12345 , FieldSourceL1 , "" )
165+ c .HashFieldValue ("User" , "name" , nil , []byte (`"Alice"` ), "" , 12345 , FieldSourceL1 , "" )
166166
167167 snap := c .Snapshot ()
168168 assert .Equal (t , 1 , len (snap .FieldHashes ))
@@ -174,9 +174,9 @@ func TestCacheAnalyticsCollector_FieldHashing(t *testing.T) {
174174 t .Run ("field source tracking" , func (t * testing.T ) {
175175 c := NewCacheAnalyticsCollector ()
176176
177- c .HashFieldValue ("User" , "name" , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
178- c .HashFieldValue ("User" , "name" , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceL1 , "" )
179- c .HashFieldValue ("User" , "name" , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceL2 , "" )
177+ c .HashFieldValue ("User" , "name" , nil , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
178+ c .HashFieldValue ("User" , "name" , nil , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceL1 , "" )
179+ c .HashFieldValue ("User" , "name" , nil , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceL2 , "" )
180180
181181 snap := c .Snapshot ()
182182 assert .Equal (t , 3 , len (snap .FieldHashes ))
@@ -1564,7 +1564,7 @@ func TestFieldSourceShadowCached(t *testing.T) {
15641564 t .Run ("HashFieldValue with FieldSourceShadowCached" , func (t * testing.T ) {
15651565 c := NewCacheAnalyticsCollector ()
15661566
1567- c .HashFieldValue ("User" , "username" , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceShadowCached , "" )
1567+ c .HashFieldValue ("User" , "username" , nil , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceShadowCached , "" )
15681568
15691569 snap := c .Snapshot ()
15701570 require .Equal (t , 1 , len (snap .FieldHashes ))
@@ -1577,8 +1577,8 @@ func TestFieldSourceShadowCached(t *testing.T) {
15771577 t .Run ("can distinguish from other sources" , func (t * testing.T ) {
15781578 c := NewCacheAnalyticsCollector ()
15791579
1580- c .HashFieldValue ("User" , "name" , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
1581- c .HashFieldValue ("User" , "name" , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceShadowCached , "" )
1580+ c .HashFieldValue ("User" , "name" , nil , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
1581+ c .HashFieldValue ("User" , "name" , nil , []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceShadowCached , "" )
15821582
15831583 snap := c .Snapshot ()
15841584 require .Equal (t , 2 , len (snap .FieldHashes ))
@@ -1589,6 +1589,99 @@ func TestFieldSourceShadowCached(t *testing.T) {
15891589 })
15901590}
15911591
1592+ // TestCacheAnalyticsCollector_FieldPath verifies the FieldPath dimension on
1593+ // EntityFieldHash, which disambiguates value-type scalars nested at arbitrary
1594+ // depth under an entity (e.g. User.address.street vs User.profile.name).
1595+ // EntityType remains the cache identity in all cases; schema-aware consumers
1596+ // resolve the parent type by walking FieldPath against the schema.
1597+ func TestCacheAnalyticsCollector_FieldPath (t * testing.T ) {
1598+ t .Run ("direct entity scalar has nil field path" , func (t * testing.T ) {
1599+ c := NewCacheAnalyticsCollector ()
1600+
1601+ // User.email — direct scalar on the entity, no value-type traversal.
1602+ c .HashFieldValue ("User" , "email" , nil , []byte (`"a@b.com"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
1603+
1604+ snap := c .Snapshot ()
1605+ require .Equal (t , 1 , len (snap .FieldHashes ))
1606+ assert .Equal (t , "User" , snap .FieldHashes [0 ].EntityType )
1607+ assert .Equal (t , "email" , snap .FieldHashes [0 ].FieldName )
1608+ assert .Nil (t , snap .FieldHashes [0 ].FieldPath )
1609+ })
1610+
1611+ t .Run ("value-type scalars under same entity attribute to that entity" , func (t * testing.T ) {
1612+ c := NewCacheAnalyticsCollector ()
1613+
1614+ // User.address.street and .city — Address is a value type under User.
1615+ // Both attribute to User for cache identity (same KeyRaw).
1616+ c .HashFieldValue ("User" , "street" , []string {"address" }, []byte (`"1 Main"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
1617+ c .HashFieldValue ("User" , "city" , []string {"address" }, []byte (`"NYC"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
1618+
1619+ snap := c .Snapshot ()
1620+ require .Equal (t , 2 , len (snap .FieldHashes ))
1621+ for i , want := range []string {"street" , "city" } {
1622+ assert .Equal (t , "User" , snap .FieldHashes [i ].EntityType )
1623+ assert .Equal (t , want , snap .FieldHashes [i ].FieldName )
1624+ assert .Equal (t , []string {"address" }, snap .FieldHashes [i ].FieldPath )
1625+ assert .Equal (t , `{"id":"1"}` , snap .FieldHashes [i ].KeyRaw )
1626+ }
1627+ })
1628+
1629+ t .Run ("siblings on different value-type parents do not collapse" , func (t * testing.T ) {
1630+ // Same FieldName "name" reached via different value-type paths
1631+ // (User.profile.name vs User.address.name) — recorded as two distinct
1632+ // events because their FieldPath differs.
1633+ c := NewCacheAnalyticsCollector ()
1634+ c .HashFieldValue ("User" , "name" , []string {"profile" }, []byte (`"Profile-Alice"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
1635+ c .HashFieldValue ("User" , "name" , []string {"address" }, []byte (`"Home"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
1636+
1637+ snap := c .Snapshot ()
1638+ require .Equal (t , 2 , len (snap .FieldHashes ))
1639+ assert .Equal (t , []string {"profile" }, snap .FieldHashes [0 ].FieldPath )
1640+ assert .Equal (t , []string {"address" }, snap .FieldHashes [1 ].FieldPath )
1641+ assert .NotEqual (t , snap .FieldHashes [0 ].FieldHash , snap .FieldHashes [1 ].FieldHash )
1642+ })
1643+
1644+ t .Run ("field path captures arbitrary nesting depth" , func (t * testing.T ) {
1645+ // User.profile.location.street is two value-type hops deep.
1646+ c := NewCacheAnalyticsCollector ()
1647+ c .HashFieldValue ("User" , "street" ,
1648+ []string {"profile" , "location" },
1649+ []byte (`"1 Main"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
1650+
1651+ snap := c .Snapshot ()
1652+ require .Equal (t , 1 , len (snap .FieldHashes ))
1653+ assert .Equal (t , "User" , snap .FieldHashes [0 ].EntityType )
1654+ assert .Equal (t , []string {"profile" , "location" }, snap .FieldHashes [0 ].FieldPath )
1655+ assert .Equal (t , "street" , snap .FieldHashes [0 ].FieldName )
1656+ })
1657+
1658+ t .Run ("field path is copied so caller scratch buffers can be reused" , func (t * testing.T ) {
1659+ // Many call sites will pass a reusable scratch slice. The collector
1660+ // must defensively copy so a subsequent push by the caller does not
1661+ // mutate already-recorded events.
1662+ c := NewCacheAnalyticsCollector ()
1663+ scratch := []string {"profile" }
1664+ c .HashFieldValue ("User" , "name" , scratch ,
1665+ []byte (`"Alice"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
1666+ // Caller mutates the scratch slice after the call.
1667+ scratch [0 ] = "MUTATED"
1668+ scratch = append (scratch , "more" )
1669+
1670+ snap := c .Snapshot ()
1671+ require .Equal (t , 1 , len (snap .FieldHashes ))
1672+ assert .Equal (t , []string {"profile" }, snap .FieldHashes [0 ].FieldPath , "stored path must be unaffected by caller mutation" )
1673+ })
1674+
1675+ t .Run ("empty field path is stored as nil for direct entity scalars" , func (t * testing.T ) {
1676+ c := NewCacheAnalyticsCollector ()
1677+ c .HashFieldValue ("User" , "email" , nil ,
1678+ []byte (`"a@b"` ), `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
1679+ snap := c .Snapshot ()
1680+ require .Equal (t , 1 , len (snap .FieldHashes ))
1681+ assert .Nil (t , snap .FieldHashes [0 ].FieldPath , "direct entity scalars should record nil FieldPath" )
1682+ })
1683+ }
1684+
15921685// TestShadowComparisonEvent_Recording verifies that shadow comparison events
15931686// capture all fields (hash, size, age, TTL) needed to detect staleness.
15941687func TestShadowComparisonEvent_Recording (t * testing.T ) {
@@ -1755,7 +1848,7 @@ func BenchmarkFieldHashing(b *testing.B) {
17551848
17561849 b .ResetTimer ()
17571850 for b .Loop () {
1758- c .HashFieldValue ("User" , "id" , value , `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
1851+ c .HashFieldValue ("User" , "id" , nil , value , `{"id":"1"}` , 0 , FieldSourceSubgraph , "" )
17591852 }
17601853}
17611854
@@ -2056,7 +2149,7 @@ func TestSnapshotSlicesAreIndependent(t *testing.T) {
20562149 c .RecordError (SubgraphErrorEvent {DataSource : "ds-orig" })
20572150 c .RecordMutationEvent (MutationEvent {EntityType : "User-orig" })
20582151 c .RecordCacheOperationError (CacheOperationError {DataSource : "ds-orig" })
2059- c .HashFieldValue ("User-orig" , "name" , []byte (`"a"` ), "k-orig" , 1 , FieldSourceL1 , "" )
2152+ c .HashFieldValue ("User-orig" , "name" , nil , []byte (`"a"` ), "k-orig" , 1 , FieldSourceL1 , "" )
20602153
20612154 snap := c .Snapshot ()
20622155
@@ -2078,7 +2171,7 @@ func TestSnapshotSlicesAreIndependent(t *testing.T) {
20782171 c .RecordError (SubgraphErrorEvent {DataSource : "ds-new" })
20792172 c .RecordMutationEvent (MutationEvent {EntityType : "User-new" })
20802173 c .RecordCacheOperationError (CacheOperationError {DataSource : "ds-new" })
2081- c .HashFieldValue ("User-new" , "name" , []byte (`"z"` ), "k-new" , 2 , FieldSourceL2 , "" )
2174+ c .HashFieldValue ("User-new" , "name" , nil , []byte (`"z"` ), "k-new" , 2 , FieldSourceL2 , "" )
20822175 }
20832176
20842177 // Full-slice assertions — snapshot must still show the original events.
0 commit comments