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]