CASSGO-118 Encode nil map value for UDTs as NULL#1942
Conversation
|
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 |
| EncodeNilMapAsInitilizedUDT bool | ||
| // Supresses the warning that is emitted when EncodeNilMapAsInitilizedUDT is enabled. | ||
| SuppressEncodeNilMapAsInitilizedUDTWarning bool |
There was a problem hiding this comment.
I don't really like this approach of warning people, but this is the best I ended up with...
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
}- Encode as non-null when data holds
map[string]any(nil) - 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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Decoding depends on what is written in the DB:
- Null value - gocql decodes as nil map
- something like this
{street: null, zip: null}- gocql decodes as udt with null values
The behavior is consistent between v1.7 and v2
| 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() |
There was a problem hiding this comment.
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.
| scanned := insertNilMapAndScan(t, session, 1) | ||
| expected := map[string]interface{}{"field_a": "", "field_b": 0} |
There was a problem hiding this comment.
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}There was a problem hiding this comment.
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.
| // ---+------------------------------- | ||
| // | ||
| // Default: true | ||
| EncodeNilMapAsInitilizedUDT bool |
There was a problem hiding this comment.
Shouldn't the default be the zero value in Go? In other words, this should probably be EncodeNilMapAsNULL instead.
There was a problem hiding this comment.
I like the name, but what do you mean by the first part? The change doesn't affect the driver's udt decoding behavior
| 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() |
There was a problem hiding this comment.
This will warn for people even if they're not using UDTs which seems overly annoying and unnecessary.
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
@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.
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
What is returned by the driver is basically what has been written to the DB:
- UDT with null fields in DB - gocql decodes as a map with key pairs
field_1 = nil,field_n => nil - If UDT is null in DB - gocql decodes it as a nil map
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
|
Alright, I have updated the PR with what we discussed with @jameshartig I added a public method Also, I have added a |
| 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 |
There was a problem hiding this comment.
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...
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