Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JSON.def_to_json #4772

Closed

Conversation

straight-shoota
Copy link
Member

@straight-shoota straight-shoota commented Aug 1, 2017

This puts the def to_json generator from JSON.mapping in a separate macro to make it individually accessible where automatically declared instance variables and parser are not needed. This comes in two flavors: one for converting self to JSON (this is the same as JSON.mapping without the extra features) and one which accepts the object-to-be-converted as an argument - this is useful to define JSON converters.
Further documentation and specs are included.

This PR was extracted from #4746 which also shows an exemplary usecase.

json.object do
{% for key, options in mappings %}
{% unless options.is_a?(HashLiteral) || options.is_a?(NamedTupleLiteral) %}
{% options = {nil: nil} %}
Copy link
Member Author

Choose a reason for hiding this comment

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

This looks ugly but I couldn't find a way to define an empty NamedTupleLiteral. Suggestions are welcome.

Copy link
Contributor

Choose a reason for hiding this comment

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

@straight-shoota NamedTuple.new.

Copy link
Contributor

@bew bew Aug 14, 2017

Choose a reason for hiding this comment

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

@RX14 won't work: https://carc.in/#/r/2iyw, @straight-shoota wants to assign a literal, so it can be used as a container later in the macro

straight-shoota added a commit to straight-shoota/crystal that referenced this pull request Aug 1, 2017
straight-shoota added a commit to straight-shoota/crystal that referenced this pull request Aug 1, 2017
@straight-shoota
Copy link
Member Author

straight-shoota commented Aug 1, 2017

CI checks are failing because of a formatter issue. I couldn't isolate the reason for this but it looks like it might be related to #4769 - at least it occurs at a similar location, inside field_to_json. I'd be happy if someone could take a look at this! Maybe @makenowjust ? =)

straight-shoota added a commit to straight-shoota/crystal that referenced this pull request Aug 1, 2017
@makenowjust
Copy link
Contributor

@straight-shoota done in #4801.

@straight-shoota
Copy link
Member Author

Rebased on master with #4801. Builds are still failing because the last commit removes the workaround and the default compiler (0.23.1) does not have the fix from #4801. The compiler needs to be built from current master. I guess this won't work without nightly builds. Should I keep the workaround in for now?

@RX14 What do you think about this PR in general?

@RX14
Copy link
Contributor

RX14 commented Aug 11, 2017

I don't understand the point of this addition, or how it relates to JSON.mapping.

Also, all releases must be buildable by the last release. Nobody is going to merge this before it passes CI.

@straight-shoota
Copy link
Member Author

Alright, so I'll keep the workaround until the next compiler version is released.

JSON.mapping consists of three individual tasks all three are based on the fields defined in properties.

  • define instance variables
  • define an initializer to read object state from a JSON::PullParser
  • define a to_json method to export object state with a JSON::Builder

For some usecases (like exporting the compiler's type and method definitions to json) there is no need to define ivars nor a parser which would both come with JSON.mapping.
So currently, you have to define a plain to_json method manually. Such methods have very repetitive code and can easily abstracted. It is nice to not have to write the same stuff over and over again but have a simple macro which creates such a to_json method. An additional benefit of this macro is, that the behaviour is no longer expressed in code but simple declarative data (a named tuple) similar to properties of JSON.mapping.

This functionality was for the most part already implemented in JSON.mapping but it could not be used individually without the ivars and parser. So I extracted this part of JSON.mapping into a macro JSON.def_to_json which is employed by JSON.mapping but can also be used directly.

# whose keys will define JSON properties.
#
# The value of each key can either be `true` or a hash or named tuple literal with the following options:
# * **property**: the property name on the Crystal object (as opposed to the key in the in the JSON document)
Copy link
Contributor

Choose a reason for hiding this comment

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

typo: in the in the

end

# :nodoc:
macro field_to_json(value_name, field_name, options)
Copy link
Contributor

Choose a reason for hiding this comment

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

this macro name and its arguments are inconsistent:

  • field_to_json: you want to generate the code that will transform the crystal-field to json. So here field == crystal-field.
  • value_name: wut?
  • field_name: from the macro name, we could understand that it's the crystal-field name. In fact it's the json-key name.

I think that to keep things coherent you can either:

  • Change the macro name to value_to_json
  • Change the argument field_name to key or json_key, and the argument value_name to field or field_name.

Copy link
Member Author

Choose a reason for hiding this comment

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

I guess I did this a bit sloppy because it is only an internal macro and was refactored quite a few times... ;)

But I like your suggestions, thanks for reviewing!

  • I think I'll change the macro name to emit_value_to_json - it's better to have a verb.
  • I am not sure about field_name because it is technically not always referring to a field but might as well be an ordinary method returning a value unrelated to an instance variable.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think I'll change the macro name to emit_value_to_json - it's better to have a verb.

right, it's good too!

I am not sure about field_name because it is technically not always referring to a field but might as well be an ordinary method returning a value unrelated to an instance variable.

True, then maybe getter_name ? but that's not ok with @var accessor. or just accessor_name?

Copy link
Member Author

Choose a reason for hiding this comment

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

As explained below it does not necessarily have to refer to a getter or any method on self but may in fact be any expression. So maybe better just use value_expression?

::JSON.def_to_json({{type}}, {{mappings}})
end

# The `StringConverter` has a class method `to_json` wich can be used as a converter for `JSON.def_to_json`. The value is added
Copy link
Contributor

Choose a reason for hiding this comment

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

typo: wich => which

# whose keys will define JSON properties.
#
# The value of each key can either be `true` or a hash or named tuple literal with the following options:
# * **property**: the property name on the Crystal object (as opposed to the key in the JSON document)
Copy link
Contributor

Choose a reason for hiding this comment

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

In the current JSON.mapping macro, one specify the crystal's property name as key in the properties arg (your mappings arg), then you can specify an optional key if the JSON field name is different.

Here you did it the other way around, why?

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it makes more sense this way: with def_to_json you're basically describing a JSON object schema. The keys of this schema correspond to the keys of the named tuple and the value defines which value is to be assigned.
mapping on the other hand has a different focus: it primarily defines properties as instance variables and adds methods to convert them to JSON and back. Here it makes sense to have the crystal properties as keys (because that's the main part) and the JSON field names as arguments.
While the properties argument looks quite similar, both macros have a different usecase and allow different options, so I don't think it is bad to have different semantics between key and value.

For def_to_json there is also the benefit that this way it is very easy to use arbitrary expressions as value:

@name = "FooBar"
JSON.def_to_json({
  name: { property: name.downcase }
})
# to_json will output {"name":"foobar"}

This would not be possible or look very nasty if it was used as a key.

Copy link
Member Author

Choose a reason for hiding this comment

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

I guess this feature should also be noted in the documentation...

Copy link
Contributor

@bew bew Aug 14, 2017

Choose a reason for hiding this comment

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

Hmm I'm not sure I completely agree, but it makes sense, and given this concept differences I think it's acceptable (and interesting to have too!).

Also I think you should copy/paste this discussion (only the important bits: question & answer?) in the issue's conversation, so your explaination about the differences of concept can't get lost as a line comment when/if you change this particular line of code.

@bew
Copy link
Contributor

bew commented Aug 14, 2017

I would suggest value instead of property, as like you pointed out earlier it's not always a property of the object, it can be a method call!
So an example would be:

@name = "FooBar"
JSON.def_to_json({
  name: { value: name.downcase }
})

And to simplify a bit: as in JSON.mapping, where name: Type is the same as name: {type: Type}, we could say that name: name.downcase is the same as name: {value: name.downcase}

@straight-shoota
Copy link
Member Author

I've thought about that simplification. It's just that then it's difficult to have a shortcut for the case that JSON key and property name are equal. This is currently encoded by the value true (in fact it could be anything that is not a named tuple or hash literal). If the value is used as value expression by default, you'd have to have certain exceptions like that it does not apply if it is a NamedTupleLiteral or HashLiteral (because they used to hold options) and perhaps BoolLiteral or NilLiteral as well as it could be used as a shortcut for equal names.
For the typical usecase I was thinking of (like #4746) it would be mostly simple mappings with identical key and property names. But having a shortcut to define property (or value) option is also quite powerful.
I am honestly not sure which way is better.

@straight-shoota
Copy link
Member Author

straight-shoota commented Aug 14, 2017

Copied from line comments by @bew (question) and me (answer) so it doesn't get lost:

In the current JSON.mapping macro, one specify the crystal's property name as key in the properties arg (your mappings arg), then you can specify an optional key if the JSON field name is different.

Here you did it the other way around, why?

I think it makes more sense this way: with def_to_json you're basically describing a JSON object schema. The keys of this schema correspond to the keys of the named tuple and the value defines which value is to be assigned.
mapping on the other hand has a different focus: it primarily defines properties as instance variables and adds methods to convert them to JSON and back. Here it makes sense to have the crystal properties as keys (because that's the main part) and the JSON field names as arguments.
While the properties argument looks quite similar, both macros have a different usecase and allow different options, so I don't think it is bad to have different semantics between key and value.

For def_to_json there is also the benefit that this way it is very easy to use arbitrary expressions as value:

@name = "FooBar"
JSON.def_to_json({
  name: { value: name.downcase }
})
# to_json will output {"name":"foobar"}

This would not be possible or look very nasty if it was used as a key.

@straight-shoota
Copy link
Member Author

@RX14 there have been a few CI builds where only linux64 failed with fork: Cannot allocate memory (Errno).

@bew
Copy link
Contributor

bew commented Aug 14, 2017

About the simplification, here an example of what you could use:

JSON.def_to_json({
  element: _, # Use element
  node: hello.node_getter, # Use hello.node_getter
  stuff: _, # Use stuff
})

Example: https://carc.in/#/r/2j3c

I find it pretty readable, with no duplication, and this could allow the simplification I talked about earlier!

@straight-shoota
Copy link
Member Author

@bew That looks like a great idea!

@straight-shoota
Copy link
Member Author

Does this PR need any additional work?

Copy link
Contributor

@RX14 RX14 left a comment

Choose a reason for hiding this comment

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

Overall I think the documentation needs to be improved, it's dry and at times misleading.

module JSON
# The `def_to_json` macro generates a `to_json(json : JSON::Builder)` method based on provided *mappings*.
#
# It is a lightweight alternative to `JSON.mapping` if you don't need to declare instance variables and a parser.
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd phrase this in terms of "serialization-only" or "read-only" instead of "if you don't need to declare instance variables and a parser" because your docs don't get near the heart of what def_to_json is used for.

#
# It is a lightweight alternative to `JSON.mapping` if you don't need to declare instance variables and a parser.
#
# The generated method invoks `to_json(JSON::Builder)` on each of the values returned by the *value* expression,
Copy link
Contributor

Choose a reason for hiding this comment

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

what "the value expression" is is undefined before this point.

# require "json"
#
# record Location, lat : Float64, long : Float64 do
# JSON.def_to_json([lat, long])
Copy link
Contributor

Choose a reason for hiding this comment

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

Where is this array stuff defined?

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh, I forgot to change this. The array syntax was not really useful.

# * **converter**: specify an alternate type for generation. The converter must define `to_json(value, JSON::Builder)` as class methods. Examples of converters are `Time::Format` and `Time::EpochConverter` for `Time`.
# * **root**: assume the value is inside a JSON object with a given key
#
# If it is not a hash or named tuple literal, the expression will be interpreted as `value` parameter. As a shortcut `_` represents
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure about the _ shortcut, I think foo: foo is perhaps the clearest. I can see it getting terrible with long names though.

@asterite
Copy link
Member

Just a note that a more granular API isn't always good. Look at Java, for example. Many ways to do the same things is also confusing. I don't understand why JSON.mapping is not enough. If it generates a initialize(pull_parser) method that you don't need, it's not that terrible, really. Now users will have JSON.def_to_json and JSON.mapping to choose. There's also this weird location: _ syntax.

I am personally not sure about this.

@RX14
Copy link
Contributor

RX14 commented Oct 31, 2017

@asterite this is for the case where you don't want to serialize all the instance variables, only a subset, so the generated initialize would be invalid

@straight-shoota
Copy link
Member Author

straight-shoota commented Oct 31, 2017

@asterite The main problem with JSON.mapping for serialize-only is not an unnecessary initialize method, it's the instance variables. As evidenced by the usages of this macro in #4746, most of the JSON values are not stored in ivars but are actually return values from methods.
If you wanted to achieve the same, you'd have to create dedicated export types with JSON.mapping for each exportable type

For Crystal::Doc::Method this would look like this:

def to_json(builder : JSON::Builder)
  MethodExport.new(self).to_json(builder)
end

struct MethodExport
  JSON.mapping(
      id: String,
      html_id: String,
      name: String,
      doc: String,
      summary: String,
      abstract: String,
      args: String,
      args_string: String,
      source_link: String,
      def: String
    )
  def initialize(method)
    @id = method.id
    @html_id = method.html_id
    @name = method.name
    # ...
  end
end

@asterite
Copy link
Member

You can always do:

  def to_json(builder : JSON::Builder)
    builder.object do
      builder.field "name", @name
      builder.field "age", @age
    end
  end

There's no need to create an extra object with the mapping.

I'm still not sure introducing a huge refactor and more public API methods is good in this case.

@asterite
Copy link
Member

Also, once we introduce meta attributes for instance variables, all of this will be kind of obsolete. You'll just specify which instance variables should be serialized, and how. That will work for generating JSON and for parsing, like in every other programming language. So I wouldn't continue making changes to JSON just yet.

@straight-shoota
Copy link
Member Author

Yes, manually writing to a JSON::Builder is possible, but produces a lot of duplicate boilerplate code. It's certainly debateable if this is worth it. But in fact, most of the functionality is already there in JSON.mapping, this macro just exposes this to use it independently. So there is only few additional code added by this PR.

@RX14
Copy link
Contributor

RX14 commented Oct 31, 2017

Personally I don't mind the manual to_json using JSON::Builder. It's certainly not too much extra boilerplate. It's still 1 line per ivar.

@straight-shoota
Copy link
Member Author

Okay, I'll try this for #4746

straight-shoota added a commit to straight-shoota/crystal that referenced this pull request Oct 31, 2017
@straight-shoota
Copy link
Member Author

I don't think this is worth pursuing anymore, given that building the JSON manually is pretty straightforward.

@straight-shoota straight-shoota deleted the jm-def-to-json branch June 13, 2018 08:39
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.

None yet

5 participants