This is an automated email from the ASF dual-hosted git repository.

worryg0d pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/cassandra-gocql-driver.git


The following commit(s) were added to refs/heads/trunk by this push:
     new 33f97ee1 Encode nil map value for UDTs as NULL
33f97ee1 is described below

commit 33f97ee1ea89331817f8b1fea02f4d4eab4e23b6
Author: Bohdan Siryk <[email protected]>
AuthorDate: Tue Apr 7 17:35:59 2026 +0300

    Encode nil map value for UDTs as NULL
    
    Previously, the driver was encoding nil map values for UDT-typed columns as
    an initialized map with zero values for each UDT field.
    This patch introduces a new RegisteredTypes.WithNilMapAsNullUDT() option 
that changes
    codec behaviour to encode nil maps as CQL NULL values in the storage.
    
    Also, this patch fixes codecs references on a RegisteredTypes.Copy() call 
to point
    to the newly created RegisteredTypes copy instead of the original one.
    
    Patch by Bohdan Siryk, João Reis; reviewed by João Reis for CASSGO-118, 
CASSGO-141
---
 cluster_test.go     |   3 ++
 doc.go              |   9 +++++
 integration_test.go |  62 ++++++++++++++++++++++++++++++
 marshal.go          |  34 +++++++++++++----
 marshal_test.go     |  34 +++++++++++++++++
 types.go            |  80 ++++++++++++++++++++++++++++++++++++++-
 types_test.go       | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 320 insertions(+), 9 deletions(-)

diff --git a/cluster_test.go b/cluster_test.go
index 61ee8113..74972bbe 100644
--- a/cluster_test.go
+++ b/cluster_test.go
@@ -52,6 +52,9 @@ func TestNewCluster_Defaults(t *testing.T) {
                reflect.DeepEqual(&SimpleConvictionPolicy{}, 
cfg.ConvictionPolicy))
        assertTrue(t, "cluster config reconnection policy",
                reflect.DeepEqual(&ConstantReconnectionPolicy{MaxRetries: 3, 
Interval: 1 * time.Second}, cfg.ReconnectionPolicy))
+       assertEqual(t, "metadata config", MetadataConfig{
+               CacheMode: Full,
+       }, cfg.Metadata)
 }
 
 func TestNewCluster_WithHosts(t *testing.T) {
diff --git a/doc.go b/doc.go
index a05a5e33..8629637b 100644
--- a/doc.go
+++ b/doc.go
@@ -761,10 +761,19 @@
 //     err := session.Query("SELECT address FROM users WHERE id = ?",
 //             userID).Scan(&udtMap)
 //
+//
 //     // Access fields
 //     street := udtMap["street"].(string)
 //     zipCode := udtMap["zip_code"].(int)
 //
+// Note: By default, nil maps `map[string]interface{}(nil)` are encoded as 
empty UDTs instead of CQL NULL value. Example:
+//
+//     SELECT id, value FROM users WHERE id = 1;
+//     id | value
+//     1  | {field_a: null, field_b: null}
+//
+// If you need to insert nil maps as CQL NULL values for UDT types, you should 
consider using the [RegisteredTypes.WithNilMapAsNullUDT] option.
+//
 // Advanced Custom Marshaling:
 //
 // For complex scenarios, implement the UDTMarshaler and UDTUnmarshaler 
interfaces:
diff --git a/integration_test.go b/integration_test.go
index 27127200..573d410e 100644
--- a/integration_test.go
+++ b/integration_test.go
@@ -1114,3 +1114,65 @@ func TestHostSelectionPolicyKeyspaceChangedEvents(t 
*testing.T) {
        require.Equal(t, ksName, events[0].Keyspace)
        require.Equal(t, SchemaChangeTypeDropped, events[0].Change)
 }
+
+func TestUDT_EncodeNilMap(t *testing.T) {
+       session := createSession(t)
+       defer session.Close()
+
+       err := createTable(session, `CREATE TYPE IF NOT EXISTS 
gocql_test.encode_nil_map_udt (
+               field_a text,
+               field_b int
+       );`)
+       require.NoError(t, err)
+
+       err = createTable(session, `CREATE TABLE IF NOT EXISTS 
gocql_test.encode_nil_map_udt_table (
+               id int PRIMARY KEY,
+               value frozen<encode_nil_map_udt>
+       );`)
+       require.NoError(t, err)
+       session.Close()
+
+       insertNilMapAndScan := func(t *testing.T, session *Session, recordID 
int) map[string]interface{} {
+               ctx := context.Background()
+               t.Helper()
+               nilMap := map[string]interface{}(nil)
+               err = session.Query("INSERT INTO encode_nil_map_udt_table (id, 
value) VALUES (?, ?)", recordID, nilMap).ExecContext(ctx)
+               require.NoError(t, err)
+               var scanned map[string]interface{}
+               err = session.Query("SELECT value FROM encode_nil_map_udt_table 
WHERE id = ?", recordID).ScanContext(ctx, &scanned)
+               require.NoError(t, err)
+               return scanned
+       }
+
+       // When the table contains such a value:
+       // id | value
+       // 1  | {field_a: null, field_b: null}
+       // The legacy behavior is to scan it as:
+       // {field_a: "", field_b: 0}
+       expectedOld := map[string]interface{}{"field_a": "", "field_b": 0}
+
+       t.Run("encode nil map as initialized UDT with null values / default 
legacy behavior", func(t *testing.T) {
+               session := createSession(t)
+               defer session.Close()
+               scanned := insertNilMapAndScan(t, session, 1)
+               require.Equal(t, expectedOld, scanned)
+       })
+
+       t.Run("encode nil map as initialized UDT with null values / enabled 
legacy behavior", func(t *testing.T) {
+               session := createSession(t, func(config *ClusterConfig) {
+                       config.RegisteredTypes = 
GlobalTypes.WithNilMapAsNullUDT(false)
+               })
+               defer session.Close()
+               scanned := insertNilMapAndScan(t, session, 2)
+               require.Equal(t, expectedOld, scanned)
+       })
+
+       t.Run("encode nil map as NULL value / enabled new behavior", func(t 
*testing.T) {
+               session := createSession(t, func(config *ClusterConfig) {
+                       config.RegisteredTypes = 
GlobalTypes.WithNilMapAsNullUDT(true)
+               })
+               defer session.Close()
+               scanned := insertNilMapAndScan(t, session, 3)
+               require.Nil(t, scanned)
+       })
+}
diff --git a/marshal.go b/marshal.go
index fb171750..ef1bfbbc 100644
--- a/marshal.go
+++ b/marshal.go
@@ -2725,6 +2725,11 @@ type UDTUnmarshaler interface {
 
 type udtCQLType struct {
        types *RegisteredTypes
+
+       // encodeNilMapAsNull enables encoding nil map[string]interface{} 
values as
+       // CQL NULL. The legacy behavior is to encode them as initialized UDTs 
with
+       // all fields set to NULL. It is copied into every UDTTypeInfo built by 
this codec.
+       encodeNilMapAsNull bool
 }
 
 // Params returns the types to build the slice of params for 
TypeInfoFromParams.
@@ -2738,7 +2743,7 @@ func (udtCQLType) Params(proto int) []interface{} {
 
 // TypeInfoFromParams builds a TypeInfo implementation for the composite type 
with
 // the given parameters.
-func (udtCQLType) TypeInfoFromParams(proto int, params []interface{}) 
(TypeInfo, error) {
+func (u udtCQLType) TypeInfoFromParams(proto int, params []interface{}) 
(TypeInfo, error) {
        if len(params) != 3 {
                return nil, fmt.Errorf("expected 3 param for udt, got %d", 
len(params))
        }
@@ -2755,9 +2760,10 @@ func (udtCQLType) TypeInfoFromParams(proto int, params 
[]interface{}) (TypeInfo,
                return nil, fmt.Errorf("expected []UDTField for udt, got %T", 
params[2])
        }
        return UDTTypeInfo{
-               Keyspace: keyspace,
-               Name:     name,
-               Elements: elements,
+               Keyspace:           keyspace,
+               Name:               name,
+               Elements:           elements,
+               EncodeNilMapAsNull: u.encodeNilMapAsNull,
        }, nil
 }
 
@@ -2775,8 +2781,9 @@ func (u udtCQLType) TypeInfoFromString(proto int, name 
string) (TypeInfo, error)
                // first is keyspace, second is hex(name), third is elements
                name, _ := hex.DecodeString(parts[1])
                ti := UDTTypeInfo{
-                       Keyspace: parts[0],
-                       Name:     string(name),
+                       Keyspace:           parts[0],
+                       Name:               string(name),
+                       EncodeNilMapAsNull: u.encodeNilMapAsNull,
                }
                ti.Elements = make([]UDTField, 0, len(parts)-2)
                for i := 2; i < len(parts); i++ {
@@ -2805,7 +2812,9 @@ func (u udtCQLType) TypeInfoFromString(proto int, name 
string) (TypeInfo, error)
                return ti, nil
        }
        // we can't get the name or anything so we'll just try to parse the 
elements
-       ti := UDTTypeInfo{}
+       ti := UDTTypeInfo{
+               EncodeNilMapAsNull: u.encodeNilMapAsNull,
+       }
        ti.Elements = make([]UDTField, 0, len(parts))
        for _, part := range parts {
                et, err := u.types.typeInfoFromString(proto, part)
@@ -2832,6 +2841,11 @@ type UDTTypeInfo struct {
        Keyspace string
        Name     string
        Elements []UDTField
+
+       // Enables the new behavior of encoding nil maps as CQL NULL values.
+       // The legacy behavior is to encode nil maps as initialized UDTs with 
all its fields are having NULL values.
+       // See [RegisteredTypes.WithNilMapAsNullUDT] for more details.
+       EncodeNilMapAsNull bool
 }
 
 func (u UDTTypeInfo) Type() Type {
@@ -2861,6 +2875,12 @@ func (udt UDTTypeInfo) Marshal(value interface{}) 
([]byte, error) {
 
                return buf, nil
        case map[string]interface{}:
+               if v == nil && udt.EncodeNilMapAsNull {
+                       // Nullable UDTs encoding for maps is enabled,
+                       // so it should return nil and framer encodes 
[]byte(nil) as CQL NULL.
+                       return nil, nil
+               }
+
                var buf []byte
                for i := range udt.Elements {
                        val, ok := v[udt.Elements[i].Name]
diff --git a/marshal_test.go b/marshal_test.go
index 4f6afb8d..1118b78c 100644
--- a/marshal_test.go
+++ b/marshal_test.go
@@ -2149,6 +2149,40 @@ func TestMarshalUDTMap(t *testing.T) {
                },
        }
 
+       t.Run("encode nil map as initialized UDT with null values", func(t 
*testing.T) {
+               typeInfoCopy := typeInfo
+               typeInfoCopy.EncodeNilMapAsNull = false
+               value := map[string]interface{}(nil)
+               // All UDT fields are set to NULL.
+               expected := 
[]byte("\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff")
+
+               data, err := Marshal(typeInfoCopy, value)
+               if err != nil {
+                       t.Errorf("got error %#v", err)
+               }
+
+               if !reflect.DeepEqual(data, expected) {
+                       t.Errorf("expected value %#v, got value %#v", expected, 
data)
+               }
+       })
+
+       t.Run("nil map as NULL value", func(t *testing.T) {
+               typeInfoCopy := typeInfo
+               typeInfoCopy.EncodeNilMapAsNull = true
+
+               value := map[string]interface{}(nil)
+               expected := []byte(nil)
+
+               data, err := Marshal(typeInfoCopy, value)
+               if err != nil {
+                       t.Errorf("got error %#v", err)
+               }
+
+               if !reflect.DeepEqual(data, expected) {
+                       t.Errorf("got value %#v", data)
+               }
+       })
+
        t.Run("partially bound", func(t *testing.T) {
                value := map[string]interface{}{
                        "y": 2,
diff --git a/types.go b/types.go
index a3289a51..545bf372 100644
--- a/types.go
+++ b/types.go
@@ -594,7 +594,7 @@ func (r *RegisteredTypes) Copy() *RegisteredTypes {
        copy := &RegisteredTypes{}
        copy.init()
        for typ, t := range r.byType {
-               copy.byType[typ] = t
+               copy.byType[typ] = copy.rebind(t, r)
        }
        for typ, t := range r.simples {
                copy.simples[typ] = t
@@ -603,7 +603,83 @@ func (r *RegisteredTypes) Copy() *RegisteredTypes {
                copy.byString[name] = typ
        }
        for name, t := range r.custom {
-               copy.custom[name] = t
+               copy.custom[name] = copy.rebind(t, r)
+       }
+       return copy
+}
+
+// rebind returns t pointing at r instead of from if t is one of the built-in
+// codecs that keep a reference to their RegisteredTypes. They use it to parse
+// nested types from type strings, so a copy must not keep resolving nested
+// types through the registry it was copied from.
+// Codecs that are not built in are returned unchanged.
+func (r *RegisteredTypes) rebind(t CQLType, from *RegisteredTypes) CQLType {
+       switch c := t.(type) {
+       case listSetCQLType:
+               if c.types == from {
+                       c.types = r
+               }
+               return c
+       case mapCQLType:
+               if c.types == from {
+                       c.types = r
+               }
+               return c
+       case tupleCQLType:
+               if c.types == from {
+                       c.types = r
+               }
+               return c
+       case udtCQLType:
+               if c.types == from {
+                       c.types = r
+               }
+               return c
+       case vectorCQLType:
+               if c.types == from {
+                       c.types = r
+               }
+               return c
+       }
+       return t
+}
+
+// WithNilMapAsNullUDT creates a shallow copy of the RegisteredTypes with the 
nil map as null UDT flag set to the given value.
+// It has no effect if the UDT type was registered with a custom CQLType 
instead of the built-in one.
+//
+// If enabled, a nil map[string]interface{} bound to a UDT is encoded as CQL 
NULL.
+//
+// IMPORTANT: Rows written with legacy behavior will remain non-NULL, so 
mixing legacy and new behavior on the same table
+// yields different stored values for the same input.
+//
+// When disabled, UDTs will be encoded as initialized UDT values with all its 
fields set to NULL when passed map[string]any(nil).
+// For example, there are following UDT and table definitions:
+//
+//     CREATE TYPE my_udt (field_a text, field_b int);
+//     CREATE TABLE my_table (id int PRIMARY KEY, value frozen<my_udt>);
+//
+// If this option is disabled, the following code will insert a UDT object 
with both fields set to NULL:
+//
+//     var nilMap map[string]any = nil
+//     session.Query("INSERT INTO my_table (id, value) VALUES (?, ?)", 1, 
nilMap).Exec()
+//
+// The table my_table will contain:
+//
+//     id | value
+//     1  | {field_a: null, field_b: null}
+//
+// If this option is enabled, the same code will insert a NULL value:
+//
+//     id | value
+//     1  | null
+//
+// Default: false
+func (r *RegisteredTypes) WithNilMapAsNullUDT(enabled bool) *RegisteredTypes {
+       copy := r.Copy()
+       // the copy was just created, so no lock is needed to change it
+       if u, ok := copy.byType[TypeUDT].(udtCQLType); ok {
+               u.encodeNilMapAsNull = enabled
+               copy.byType[TypeUDT] = u
        }
        return copy
 }
diff --git a/types_test.go b/types_test.go
index c3764dce..b3bc0072 100644
--- a/types_test.go
+++ b/types_test.go
@@ -118,3 +118,110 @@ func TestSplitCompositeTypes(t *testing.T) {
                }
        }
 }
+
+func TestRegisteredTypes_CopyRebindsCodecs(t *testing.T) {
+       const listOfUDT = "org.apache.cassandra.db.marshal.ListType(" +
+               
"org.apache.cassandra.db.marshal.UserType(ks,6e616d65,66:org.apache.cassandra.db.marshal.Int32Type))"
+
+       nestedUDTFlag := func(t *testing.T, rt *RegisteredTypes) bool {
+               t.Helper()
+               ti, err := rt.typeInfoFromString(protoVersion4, listOfUDT)
+               if err != nil {
+                       t.Fatalf("typeInfoFromString: %v", err)
+               }
+               list, ok := ti.(CollectionType)
+               if !ok {
+                       t.Fatalf("expected CollectionType, got %T", ti)
+               }
+               udt, ok := list.Elem.(UDTTypeInfo)
+               if !ok {
+                       t.Fatalf("expected UDTTypeInfo element, got %T", 
list.Elem)
+               }
+               return udt.EncodeNilMapAsNull
+       }
+
+       enabled := GlobalTypes.WithNilMapAsNullUDT(true)
+       disabled := enabled.WithNilMapAsNullUDT(false)
+
+       if !nestedUDTFlag(t, enabled) {
+               t.Error("nested UDT should see the flag set by 
WithNilMapAsNullUDT(true)")
+       }
+       if !nestedUDTFlag(t, enabled.Copy()) {
+               t.Error("Copy must preserve the flag for nested UDTs")
+       }
+       if nestedUDTFlag(t, disabled) {
+               t.Error("nested UDT should see the flag set by 
WithNilMapAsNullUDT(false)")
+       }
+       // creating a copy must not change the registry it was created from
+       if !nestedUDTFlag(t, enabled) {
+               t.Error("WithNilMapAsNullUDT(false) must not change the 
original")
+       }
+       if nestedUDTFlag(t, GlobalTypes) {
+               t.Error("WithNilMapAsNullUDT must not change GlobalTypes")
+       }
+}
+
+func TestRegisteredTypes_NullableUDTMaps(t *testing.T) {
+       // readTypeInfo resolves types through the framer's registry, so the 
flag
+       // must be visible for a UDT read from a frame, directly and when 
nested.
+       writeUDT := func(f *framer) {
+               f.writeShort(uint16(TypeUDT))
+               f.writeString("ks")
+               f.writeString("name")
+               f.writeShort(1)
+               f.writeString("f")
+               f.writeShort(uint16(TypeInt))
+       }
+       udtFlag := func(t *testing.T, rt *RegisteredTypes, nested bool) bool {
+               t.Helper()
+               f := newFramer(nil, protoVersion4, rt)
+               if nested {
+                       f.writeShort(uint16(TypeList))
+               }
+               writeUDT(f)
+               ti, err := f.readTypeInfo()
+               if err != nil {
+                       t.Fatalf("readTypeInfo: %v", err)
+               }
+               if nested {
+                       ti = ti.(CollectionType).Elem
+               }
+               udt, ok := ti.(UDTTypeInfo)
+               if !ok {
+                       t.Fatalf("expected UDTTypeInfo, got %T", ti)
+               }
+               return udt.EncodeNilMapAsNull
+       }
+
+       enabled := GlobalTypes.WithNilMapAsNullUDT(true)
+       for _, nested := range []bool{false, true} {
+               if !udtFlag(t, enabled, nested) {
+                       t.Errorf("nested=%v: flag should be visible after 
WithNilMapAsNullUDT(true)", nested)
+               }
+               if !udtFlag(t, enabled.Copy(), nested) {
+                       t.Errorf("nested=%v: flag should survive Copy", nested)
+               }
+               if udtFlag(t, GlobalTypes, nested) {
+                       t.Errorf("nested=%v: GlobalTypes must keep the legacy 
default", nested)
+               }
+       }
+}
+
+func TestRegisteredTypes_CopyKeepsUserCodecs(t *testing.T) {
+       // A registry that does not start from GlobalTypes can hold the user's 
own
+       // codecs for composite types. Copy, which every session goes through, 
must
+       // keep them and must not add built-in codecs the source did not have.
+       custom := SimpleCQLType{intTypeInfo{}}
+       src := &RegisteredTypes{}
+       if err := src.RegisterType(TypeList, "list", custom); err != nil {
+               t.Fatal(err)
+       }
+
+       cp := src.Copy()
+       if got := cp.byType[TypeList]; got != CQLType(custom) {
+               t.Errorf("Copy replaced the user's codec for list: got %#v", 
got)
+       }
+       if _, ok := cp.byType[TypeMap]; ok {
+               t.Error("Copy must not add built-in codecs that the source did 
not have")
+       }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to