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

Support JSON::Serializable #253

Merged

Conversation

Blacksmoke16
Copy link
Contributor

@Blacksmoke16 Blacksmoke16 commented Jul 15, 2018

This PR, fixes #253 brings about native support for JSON::Serializable. This is super exciting and probably my favorite PR so far. The benefits of this are immense, especially when working with JSON APIs.

class Foo < Granite::Base
  adapter mysql
  table_name foos

  field name : String
  field age : Int32
end

Single Object

foo = Foo.from_json(%({"name": "Granite", "age": 10}))

<Foo:0x55c77d901ea0
 @_current_callback=nil,
 @age=10,
 @created_at=nil,
 @destroyed=false,
 @errors=[],
 @id=nil,
 @name="Granite",
 @new_record=true,
 @updated_at=nil>

foo.to_json

{
  "name": "Granite",
  "age": 10
}

Array of Objects

foo = Array(Foo).from_json(%([{"name": "Granite1", "age": 10},{"name": "Granite2", "age": 50}]))

[#<Foo:0x55f66bdeaea0
  @_current_callback=nil,
  @age=10,
  @created_at=nil,
  @destroyed=false,
  @errors=[],
  @id=nil,
  @name="Granite1",
  @new_record=true,
  @updated_at=nil>,
 #<Foo:0x55f66bdeae10
  @_current_callback=nil,
  @age=50,
  @created_at=nil,
  @destroyed=false,
  @errors=[],
  @id=nil,
  @name="Granite2",
  @new_record=true,
  @updated_at=nil>]

foo.to_json

[
  {
    "name": "Granite1",
    "age": 10
  },
  {
    "name": "Granite2",
    "age": 50
  }
]

It works pretty much out of the box, but with one slight breaking change. By default, as you may have noticed above, when doing to_json it will not add nil values into the object, i.e. the id. If the user wants that, they would have to do:

@[JSON::Serializable::Options(emit_nulls: true)]
class Foo < Granite::Base
  adapter mysql
  table_name foos

  field name : String
  field age : Int32
end

foo = Foo.from_json(%({"name": "Granite", "age": 10}))

<Foo:0x55c77d901ea0
 @_current_callback=nil,
 @age=10,
 @created_at=nil,
 @destroyed=false,
 @errors=[],
 @id=nil,
 @name="Granite",
 @new_record=true,
 @updated_at=nil>

foo.to_json

{
  "name": "Granite",
  "age": 10,
  "id": null,
  "created_at": null,
  "updated_at": null
}

after_initialize

Another cool feature this brings is the after_initialize method. This method gets called after from_json is done parsing the given json. This allows you to set other fields that are not in the JSON directly or that require some more logic.

class Foo < Granite::Base
  adapter mysql
  table_name foos

  field name : String
  field age : Int32

  def after_initialize
      @age = 0
  end
end

foo = Foo.from_json(%({"name": "Granite1"}))

<Foo:0x55c77d901ea0
 @_current_callback=nil,
 @age=0,
 @created_at=nil,
 @destroyed=false,
 @errors=[],
 @id=nil,
 @name="Granite",
 @new_record=true,
 @updated_at=nil>

JSON::Serializable::Unmapped

 If the JSON::Serializable::Unmapped module is included, unknown properties in the JSON document will be stored in a Hash(String, JSON::Any). On serialization, any keys inside json_unmapped will be serialized appended to the current json object.
class Foo < Granite::Base
  include JSON::Serializable::Unmapped

  adapter mysql
  table_name foos

  field name : String
  field age : Int32
end

foo = Foo.from_json(%({"name": "Granite1", "age": 12, "foobar": true}))

#<Foo:0x55efba40ff00
 @_current_callback=nil,
 @age=12,
 @created_at=nil,
 @destroyed=false,
 @errors=[],
 @id=nil,
 @json_unmapped={"foobar" => true},
 @name="Granite1",
 @new_record=true,
 @updated_at=nil>

foo.to_json

{
  "name": "Granite",
  "age": 12,
  "foobar": true
}

on_to_json

Allows the user to tap into the to_json and do additional manipulations before returning it.

class Foo < Granite::Base
  adapter mysql
  table_name foos

  field name : String
  field age : Int32

  def on_to_json(json : JSON::Builder)
      json.field("years_young", @age)
  end
end

foo = Foo.from_json(%({"name": "Granite1", "age": 12}))

#<Foo:0x55eb12bd2f00
 @_current_callback=nil,
 @age=12,
 @created_at=nil,
 @destroyed=false,
 @errors=[],
 @id=nil,
 @name="Granite1",
 @new_record=true,
 @updated_at=nil>

foo.to_json

{
  "name": "Granite",
  "age": 12,
  "years_young": 12
}

@Blacksmoke16
Copy link
Contributor Author

Blacksmoke16 commented Jul 15, 2018

Latest commit adds support for the @[JSON::Field()] property annotations

Usage is like field name : String, json_options: {ignore: true}

Is it worth adding specs for each of these options, or at this point are we just testing the std lib?

@Blacksmoke16 Blacksmoke16 requested a review from a team July 16, 2018 12:02
Copy link
Member

@drujensen drujensen left a comment

Choose a reason for hiding this comment

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

@Blacksmoke16 This looks so much better. Thanks! 💯

@Blacksmoke16
Copy link
Contributor Author

👍 Yea no kidding, much better. Don't merge yet, think i want to include some more details in readme on at least the json_options hash on fields macro.

@drujensen
Copy link
Member

drujensen commented Jul 16, 2018

@Blacksmoke16 Is there any side affects using the native version? I recall attempting this before but the constructor required supporting the JSON::Puller or something like that.

@Blacksmoke16
Copy link
Contributor Author

This is a bit difference than the JSON.mapping version. This was just released with Crystal 0.25.0.

Using this it isn't possible to give like Model.create(JSON::ANY), would have to do Model.from_json(json_string).

All the tests passed and all the Serializable extra stuff seems to work fine so unless something comes up i think we're good.

@drujensen
Copy link
Member

aaah, I was wondering how this was possible without the constructor. I will checkout 0.25 release notes for more info. This is all excellent news! I prefer the .from_json so that is fine.

@Blacksmoke16
Copy link
Contributor Author

https://crystal-lang.org/api/0.25.1/JSON/Serializable.html

Is a good overview of it.

Copy link
Member

@robacarp robacarp left a comment

Choose a reason for hiding this comment

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

I love how much this simplifies the code.

The only danger sign I see is the dependence on JSON::Serializable's use of after_initialize, which is apparently undocumented.

The annotations feel like a hack, but that's not your doing, it's just the way the stdlib was implemented.

I have a hunch json::serializable is going to get a substantial refactor before it really solidifies in crystal. We'll see I guess.

else
"#{@field.to_s.capitalize} #{message}"
end
(@field == :base) ? @message : "#{@field.to_s.capitalize} #{message}"
Copy link
Member

Choose a reason for hiding this comment

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

Can we leave this as it was? I don't see the benefit to the more subtle syntax.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can change it back if you want. I just have a thing for ternary, easier to read and cleaner imo.

Copy link
Member

Choose a reason for hiding this comment

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

I guess this is the sort of thing that a style guide helps communicate. My preference is to avoid ternaries wherever possible.

More than anything, this is an unrelated change to the rest of the pull. I don’t feel really strong about changing this, but I’d like to avoid someone else changing it back simply because they prefer it to be the other way. It just creates more potential for bugs to sneak in and opens the door for endless bike shedding.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fair enough.

@@ -39,6 +39,9 @@ module Granite::Fields
{% for name, options in FIELDS %}
{% type = options[:type] %}
{% suffixes = options[:raise_on_nil] ? ["?", ""] : ["", "!"] %}
{% if options[:json_options] %}
@[JSON::Field({{**options[:json_options]}})]
{% end %}
Copy link
Member

Choose a reason for hiding this comment

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

clever, I like it.

@Blacksmoke16
Copy link
Contributor Author

There is no dependence on after_initialize. It is just an undocumented feature that can be used.

It could be removed and the basic implementation would still be fine. It is just a method that gets called after the from_json method is finished.

@Blacksmoke16
Copy link
Contributor Author

Updated README on JSON support. Can see it at https://github.com/Blacksmoke16/granite/tree/JSON-Serializable-support#json-support.

Otherwise this is good to merge unless one of you finds an issue.

This was referenced Jul 17, 2018
@drujensen
Copy link
Member

@robacarp can we merge this?

Copy link
Member

@robacarp robacarp left a comment

Choose a reason for hiding this comment

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

thanks @Blacksmoke16

@robacarp robacarp merged commit 4c94445 into amberframework:master Jul 17, 2018
@Blacksmoke16 Blacksmoke16 deleted the JSON-Serializable-support branch July 20, 2018 00:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants