Skip to content

CASSGO-118 Encode nil map value for UDTs as NULL#1942

Open
worryg0d wants to merge 13 commits into
apache:trunkfrom
worryg0d:cassgo-118
Open

CASSGO-118 Encode nil map value for UDTs as NULL#1942
worryg0d wants to merge 13 commits into
apache:trunkfrom
worryg0d:cassgo-118

Conversation

@worryg0d

@worryg0d worryg0d commented Apr 7, 2026

Copy link
Copy Markdown
Member

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 changes its behaviour to encode nil maps as NULL values in Cassandra.

Patch by Bohdan Siryk; reviewed by TBD for CASSGO-118

@worryg0d

worryg0d commented Apr 7, 2026

Copy link
Copy Markdown
Member Author

I'm unsure whether this should be fixed in the current major because people could rely on that behavior already, but anyway submitting a PR

Comment thread cluster.go Outdated
Comment on lines +495 to +497
EncodeNilMapAsInitilizedUDT bool
// Supresses the warning that is emitted when EncodeNilMapAsInitilizedUDT is enabled.
SuppressEncodeNilMapAsInitilizedUDTWarning bool

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really like this approach of warning people, but this is the best I ended up with...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to warn anyone? Is there a problem that the values are non-NULL? If there isn't any issue then I assume we don't need to warn.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me, this is at least not something I would expect when dealing with nil-values. For example, std json lib encodes nil values as null and vice versa. But, I found out one more thing: driver behavior is inconsistent between struct-based and map-based UDT encoding.

For example, the following code will have two different behaviors that depend on what is wrapped the data var:

func insertPerson(session *gocql.Session, id int, name string, data any) error {
	const insertQuery = `
	INSERT INTO gocqltest.persons (id, name, address) VALUES (?, ?, ?);
	`

	err := session.Query(insertQuery, id, name, addressMapData).Exec()
	if err != nil {
		return fmt.Errorf("failed to insert person: %w", err)
	}

	return nil
}
  1. Encode as non-null when data holds map[string]any(nil)
  2. Encode as null when data holds nil ptr struct. I guess this actually related to all ptr types: https://github.com/apache/cassandra-gocql-driver/blob/trunk/marshal.go#L75-L77

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But when the driver reads that data from the db how will that be decoded for both cases? And has that inconsistency been in the driver for a long time too or is it more recent?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decoding depends on what is written in the DB:

  1. Null value - gocql decodes as nil map
  2. something like this {street: null, zip: null} - gocql decodes as udt with null values

The behavior is consistent between v1.7 and v2

Comment thread types.go
copy := &RegisteredTypes{}
copy.init()
// Adding default types to the copy so collection type codecs will have a pointer to the copy instead of the original.
copy.addDefaultTypes()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As marshaling/unmarshaling is implemented on TypeInfo types, we should somehow propagate its behavior from the cluster config. Collection types such as udtCqlType hold a pointer to RegisteredTypes object to know how to marshal/unmarshal different types. The problem was that in NewSession, where we create a copy of the provided RegisteredTypes by calling RegisteredTypes.Copy(), which just initializes a new object and populates maps with values from the original map, which includes a pointer to the original RegisteredTypes.

Fixed it by calling addDefaultTypes() on a copy and adding missing types/names/aliases to the copy.

Comment thread integration_test.go
Comment on lines +1016 to +1017
scanned := insertNilMapAndScan(t, session, 1)
expected := map[string]interface{}{"field_a": "", "field_b": 0}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, it is supposed to return a scan of a map with nil fields instead of zero-valued fields, isn't it?

So we should expect having nil pointers of *string and *int types:

var fieldA *string = nil
var fieldB *int = nil
expected := map[string]interface{}{"field_a": fieldA, "field_b": fieldB}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like it makes a pointer and then unmarshals into that pointer and then dereferences it. So I think map[string]interface{}{"field_a": "", "field_b": 0} is right.

Comment thread cluster.go Outdated
// ---+-------------------------------
//
// Default: true
EncodeNilMapAsInitilizedUDT bool

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the default be the zero value in Go? In other words, this should probably be EncodeNilMapAsNULL instead.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the name, but what do you mean by the first part? The change doesn't affect the driver's udt decoding behavior

Comment thread session.go Outdated
return nil, fmt.Errorf("the default SerialConsistency level is not allowed to be anything else but SERIAL or LOCAL_SERIAL. Recived value: %v", cfg.SerialConsistency)
}

logger := cfg.newLogger()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will warn for people even if they're not using UDTs which seems overly annoying and unnecessary.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm yeah you're right. I still want to warn people about that behavior for the reasons I explained in another comment, but I'd prefer it not be too noisy...

@smiklosovic smiklosovic Apr 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jameshartig I would not warn anybody at all but that is just my preference, not going to "die on this hill". If they were affected, then they would have to be in a situation when they are 1) aware of this bug 2) their "nullity check" on UDT means that they are iterating over all fields in UDT and check if it is null themselves. I do not think there is anybody out there who is doing this. We would be having this bug reported already. Us switching to just fixing the bug and having this in notes is just OK imho.

encode nil maps as NULL values in Cassandra. - this IS the correct behavior.

But take this as just a feedback from somebody external, I do not know what you guys really prefer. Ultimately up to you.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Us switching to just fixing the bug and having this in notes is just OK imho.

I think the issue is that switching to the correct behavior might lead to user apps breaking because their data is already written as empty instead of NULL on their databases so after the user upgrades and starts writing the new data as NULL then they will end up in a situation where the old data is not NULL and the new data is NULL but in reality both data values were encoded from the same input.

I do find it odd that we've not seen this bug reported before or maybe it was and I didn't see it (I haven't been involved with the project for that long). Maybe it's because what ultimately matters is that the data that comes back from a read is the same as the data that was written even if the underlying value in the database is not the correct one...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is returned by the driver is basically what has been written to the DB:

  1. UDT with null fields in DB - gocql decodes as a map with key pairs field_1 = nil, field_n => nil
  2. If UDT is null in DB - gocql decodes it as a nil map

@worryg0d

Copy link
Copy Markdown
Member Author

Alright, I have updated the PR with what we discussed with @jameshartig

I added a public method RegisteredTypes.WithNullableUDTs(enabled bool) that changes encoding behavior. This approach enables users to override the encoding behavior globally, so any new session would inherit the desired behavior:

var GlobalTypes =  GlobalTypes.WithNullableUDTs(true)

Also, I have added a limitedLogger wrapper around StructuredLogger that limits the number of logs to each n-th, including the first one. Currently, I chose each 100th log to be logged. However, probably just logging once is sufficient?

Comment thread marshal.go
Comment on lines 2751 to 2758
return UDTTypeInfo{
Keyspace: keyspace,
Name: name,
Elements: elements,
Keyspace: keyspace,
Name: name,
Elements: elements,
encodeNilMapAsNull: u.types.encodeNilMapAsNull,
warnOnNilMap: u.types.warnOnNilMap,
logger: u.types.logger,
}, nil

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not comfortable passing configurations and the logger deep into the corresponding marshaler in such a way because it complicates things and makes it more error-prone. For example, in unit tests we're using GlobalTypes which defaults warnOnNilMap to true, so any created UDTTypeInfo object would inherit its value as well, but our unit tests don't cover it.

Right now I just intentionally called WithNullableUDTs so the warning is disabled, but this is anyway far from ideal...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants