Proposal Details

One issue that we faced in the old Go JSON implementation is the inability to remove a field entirely when marshalling to JSON (when implementing the MarshalJSON func).

This feature is still not possible in the json/v2 implementation. While it is possible to set the exported field value to JSON null, removing the field completely does not seem possible.

Is there anything I am missing or there is currently no way to achieve this?

Previously, there was a proposal to return a SkipFieldErr error from the MarshalJSON which the encoder would interpret as to remove the field from the generated json.

Comment From: dsnet

Right now we support a SkipFunc sentinel error that says that the current marshal functionality should be skipped. This is easier to implement as the semantics simply require that the marshal func simply not perform any mutating functionality. If so, the json package itself will delegate marshaling to the next available marshaling for the type. Of particular note, SkipFunc is only permitted on a MarshalToFunc and not a MarshalerTo method (of relevance, I just filed #74324).

A SkipFieldErr could also be implemented, but is little more complicated. Fortunately, the omitempty feature in v2 omits a field if it would have serialized as an empty JSON object, array, string, or null. The implementation needs to be able to roll-back the previously written object name. We can leverage the same mechanism to implement SkipFieldErr.

As a side-note, this should probably be called SkipMember since we prefer to use JSON terminology (rather than Go terminology).

Comment From: mitar

I am very for supporting SkipMember sentinel error. This is still my biggest issue with v2 API. It is really sad that custom marshal function cannot re-implement everything struct tags can do.

But to implement this feature, maybe it could be easier if instead of SkipMember sentinel error, this behavior would be triggered if implementation would call something like Skip() method on the Encoder? (For v1 my suggestion would be to return nil from MarshalJSON, and I think calling (new method) Skip() is analogous in v2.)

Comment From: mhoury

@dsnet, Thank you for your reply (and your time). I really appreciate the work on the json2 package.

Fortunately, the omitempty feature in v2 omits a field if it would have serialized as an empty JSON object, array, string, or null

The problem is when we talk JSON, we talk integrations with other systems that may or may not be written in Go. Sometimes there is a need to send a JSON property with null value or as empty string. As you know, There is a difference between the following as they have different interpretations.

{
    "name": "John Doe"
}
{
    "name": ""
}
{
    "name": null
}
{

}

and, unfortunately, "omitempty" removes all nil, empty, fields from JSON.

There are also scenarios where attaching certain fields/members to JSON needs to be done on the fly while marshaling the struct.

I have looked into MarshalToFunc and looks like it can be used, but it is a bit more work.

If I may be honest, SkipMember looks a lot more appealing than MarshalToFunc.

Finally, if adding support for a SkipMember is much work and we don't want to break existing logic, we can look into the possibility of adding a completely new function/interface like MarshalerToWithSkip, so to not modify the existing one. It would be similar to the idea of context WithCancel function.

I will take a look at the other proposal #74324 as I haven't done so yet.

Comment From: dsnet

I have looked into MarshalToFunc and looks like it can be used, but it is a bit more work. If I may be honest, SkipMember looks a lot more appealing than MarshalToFunc.

To be clear, SkipMember is an orthogonal, but related concern from MarshalToFunc.

Without #74324, we can only make SkipMember work for MarshalToFunc. With #74324, we can make SkipMember work for both MarshalJSONTo and MarshalToFunc.

Comment From: dsnet

maybe it could be easier if instead of SkipMember sentinel error, this behavior would be triggered if implementation would call something like Skip() method on the Encoder?

I'm personally not fond of this because it means that MarshalJSONTo or MarshalToFunc subtly lies about what it did. It would report nil, which would imply that it did indeed encode a single JSON value, but in reality it didn't. Returning a sentinel error signals to the caller that "I'm refusing to encode a single JSON value and the caller should go figure out what to do instead".

Of particular note, since the "json" package is what usually dispatches the call to MarshalJSONTo or MarshalToFunc, it can do the internal logic to unwrite the JSON member name, which has already been encoded up until that point.

Comment From: dsnet

Thinking about this more. If we add this, we should probably call it SkipValue with the semantic: * If the next token to encode is a JSON object name, then the entire member is skipped within the immediate parent object. * If the next token to encode is a JSON object value, then the previously written name is rolled back and the entire member is skipped within the immediate parent object. * If the next token to encode is a JSON array element, then the element is skipped within the immediate parent array.

This covers every possible case of where a JSON value can appear within a composite type (i.e., objects and arrays). I don't think there's any reason to restrict this feature to just JSON object member values as the logic within a MarshalJSONTo can introspect the current encoder state using the jsontext.Encoder.StackIndex accessor to determine which of the 3 cases above it is currently operating under.

Comment From: mhoury

The 3 points you mentioned sound pretty rational to me.

Regarding the second point, however,

If the next token to encode is a JSON object value, then the previously written name is rolled back and the entire member is skipped within the immediate parent object.

Isn't best to wait before writing the name to the buffer until the MarshalJSONTo returns instead of writing the name, checking the value and performing a roll back?

Comment From: dsnet

Isn't best to wait before writing the name to the buffer until the MarshalJSONTo returns

The challenge is that the JSON object name itself might be a type that has a MarshalJSONTo. In such a case, we need to call it with a jsontext.Encoder before we encode the value. Once you need to support that possibility, we're pretty much constrained to calling MarshalJSONTo in depth-first order. It's still possible that we still do something like this by creating a "fake" jsontext.Encoder that the name encodes into, but fundamentally doesn't encode anything.

Given the above challenge, it was deemed easier to simply have jsontext.Encoder avoid flushing its internal buffer to the underlying io.Writer anytime it is between a JSON object name and value. Thus, we can rollback the encoded name purely within the buffer alone.

Comment From: mhoury

tbh, I am still looking and analyzing the json2 codebase so I can't say I am familiar with it yet.

but it seems like this entire problem can be solved using recursion, where as you mentioned, on each new level (i,e, if the current member being marshaled is an object/array), a new (not fake but another instance) encoder is passed.

Once all child members are marshaled they are written to the parent buffer.

doing so will eliminate the need for many functions like UnwriteEmptyObjectMember, UnwriteOnlyObjectMemberName and avoidFlush.

maybe I am oversimplifying things 🙄

Comment From: dsnet

I think we're getting into the weeds of how this could be implemented. We already have the groundwork set up by how omitempty functions so we should just leverage that.

a new (not fake but another instance) encoder is passed

A jsontext.Encoder isn't just an object for encoding JSON tokens/values, but it also allows for querying the current state machine via StackPointer, StackDepth, and StackIndex accessors. You'll need to somehow replicate the state as if the MarshalJSONTo calls had been occurring in order, when in reality they are not.

There's also performance issues with repeatedly creating new encoders and merging their state into the parent encoder.