diff --git a/Dockerfile b/Dockerfile index d1ccb064..0a284878 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM crystallang/crystal:0.25.0 +FROM crystallang/crystal:0.25.1 RUN apt-get update -qq && apt-get install -y --no-install-recommends libpq-dev libsqlite3-dev libmysqlclient-dev diff --git a/README.md b/README.md index d1057603..1f62eab4 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,13 @@ end # There are various ways to instantiate a new object: namedTuple = Post.new(name: "Name", body: "I am the body") # .new NamedTuple hash = Post.new({name: "Name", body: "I am the body"}.to_h) # .new Hash -fromJson = Post.from_json(JSON.parse(%({"name": "Name", "body": "I am the body"}))).as(Post) # .from_json JSON -jsonArray = Post.from_json(JSON.parse(%([{"name": "Post1", "body": "I am the body for post1"},{"name": "Post2", "body": "I am the body for post2"}]))).as(Array(Post)) # .from_json array of JSON +fromJson = Post.from_json(JSON.parse(%({"name": "Name", "body": "I am the body"}))) # .from_json JSON +jsonArray = Array(Post).from_json(JSON.parse(%([{"name": "Post1", "body": "I am the body for post1"},{"name": "Post2", "body": "I am the body for post2"}]))) # .from_json array of JSON # Can also use .create to automatically instantiate and save a model Post.create(name: "First Post", body: "I get saved automatically") # Instantiates and saved the post ``` -**Note: When using `.from_json` you must specify the type that will be returned. I.e. single object or an array of objects.** +**Note: When using `.from_json/.to_json` see [JSON::Serialization](https://crystal-lang.org/api/0.25.1/JSON/Serializable.html) for more details.** You can disable the timestamps for SqlLite since TIMESTAMP is not supported for this database: @@ -718,6 +718,190 @@ class Post < Granite::Base end ``` +### JSON Support + +A few handy things that come from the `JSON::Serializable` support. See [JSON::Serializable Docs](https://crystal-lang.org/api/0.25.1/JSON/Serializable.html) for more information. + +#### JSON::Field + +Allows for control over the serialization and deserialization of instance variables. + +```Crystal +class Foo < Granite::Base + adapter mysql + table_name foos + + field name : String + field password : String, json_options: {ignore: true} # skip this field in serialization and deserialization + field age : Int32, json_options: {key: "HowOldTheyAre"} # the value of the key in the json object + field todayBirthday : Bool, json_options: {emit_null: true} # emits a null value for nilable property + field isNil : Bool +end +``` + +`foo = Foo.from_json(%({"name": "Granite1", "HowOldTheyAre": 12, "password": "12345"}))` + +```Crystal +# +``` + +`foo.to_json` + +```JSON +{ + "name":"Granite1", + "HowOldTheyAre":12, + "todayBirthday":null +} +``` + +Notice how `isNil` is omitted from the JSON output since it is Nil. If you wish to always show Nil instance variables on a class level you can do: + +```Crystal +@[JSON::Serializable::Options(emit_nulls: true)] +class Foo < Granite::Base + adapter mysql + table_name foos + + field name : String + field age : Int32 +end +``` + +This would be functionally the same as adding `json_options: {emit_null: true}` on each property. + +#### after_initialize + +This method gets called after `from_json` is done parsing the given JSON string. This allows you to set other fields that are not in the JSON directly or that require some more logic. + +```Crystal +class Foo < Granite::Base + adapter mysql + table_name foos + + field name : String + field age : Int32 + field date_added : Time + + def after_initialize + @date_added = Time.utc_now + end +end +``` + +`foo = Foo.from_json(%({"name": "Granite1"}))` + +```Crystal + +``` + +#### 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. + +```Crystal +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}))` + +```Crystal + true}, + @name="Granite1", + @new_record=true, + @updated_at=nil> +``` + +`foo.to_json` + +```JSON +{ + "name": "Granite", + "age": 12, + "foobar": true +} +``` + +#### on_to_json + +Allows doing additional manipulations before returning from `to_json`. + +```Crystal +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}))` + +```Crystal + +``` + +`foo.to_json` + +```JSON +{ + "name": "Granite", + "age": 12, + "years_young": 12 +} +``` + ### Migration - `migrator` provides `drop`, `create` and `drop_and_create` methods diff --git a/spec/granite/transactions/create_spec.cr b/spec/granite/transactions/create_spec.cr index 66f90a83..96abcd0b 100644 --- a/spec/granite/transactions/create_spec.cr +++ b/spec/granite/transactions/create_spec.cr @@ -14,23 +14,6 @@ module {{adapter.capitalize.id}} parent.persisted?.should be_false end - it "takes JSON::Any" do - json_str = %({"name": "json::anyReview", "downvotes": 99, "upvotes": 2, "sentiment": 1.23, "interest": 4.56, "published": true}) - review_json = JSON.parse(json_str) - - review_json.is_a?(JSON::Any).should be_true - - review = Review.create(review_json) - review.name.should eq "json::anyReview" - review.downvotes.should eq 99_i32 - review.upvotes.should eq 2_i64 - review.sentiment.should eq 1.23_f32 - review.interest.should eq 4.56_f64 - review.published.should eq true - review.created_at.to_s.should eq Time.utc_now.to_s - review.persisted?.should be_true - end - it "doesn't have a race condition on IDs" do channel = Channel(Int64).new diff --git a/spec/granite_spec.cr b/spec/granite_spec.cr index 21cd6868..bbbeaf45 100644 --- a/spec/granite_spec.cr +++ b/spec/granite_spec.cr @@ -1,129 +1,91 @@ require "./spec_helper" -require "../src/adapter/pg" - -class Todo < Granite::Base - adapter pg - field name : String - field priority : Int32 - timestamps -end - -class Review < Granite::Base - adapter pg - field name : String - field user_id : Int32 - field upvotes : Int64 - field sentiment : Float32 - field interest : Float64 - field published : Bool - field created_at : Time -end - -class WebSite < Granite::Base - adapter pg - primary custom_id : Int32 - field name : String - - validate :name, "Name cannot be blank", ->(s : WebSite) do - !s.name.to_s.blank? - end -end - -describe Granite::Base do - it "should create a new todo object with name set" do - t = Todo.new(name: "Elorest") - t.name.should eq "Elorest" - end - - it "takes JSON::Any" do - time_now = Time.now.at_beginning_of_second - json_str = %({"name": "json::anyReview", "user_id": 99, "upvotes": 2, "sentiment": 1.23, "interest": 4.56, "published": true, "created_at": "#{time_now.to_s(Granite::DATETIME_FORMAT)}"}) - review_json = JSON.parse(json_str) - - review_json.is_a?(JSON::Any).should be_true - - review = Review.from_json(review_json).as(Review) - review.name.should eq "json::anyReview" - review.user_id.should eq 99_i32 - review.upvotes.should eq 2_i64 - review.sentiment.should eq 1.23_f32 - review.interest.should eq 4.56_f64 - review.published.should eq true - review.created_at.should eq time_now - end - - it "takes JSON::Any Array" do - json_str = %([{"name": "web1"},{"name": "web2"},{"name": "web3"}]) - website_json = JSON.parse(json_str) - - website_json.is_a?(JSON::Any).should be_true - - web_sites = WebSite.from_json(website_json).as(Array(WebSite)) - - web_sites[0].name.should eq "web1" - web_sites[1].name.should eq "web2" - web_sites[2].name.should eq "web3" - end - - describe "#to_h" do - it "convert object to hash" do - t = Todo.new(name: "test todo", priority: 20) - result = {"id" => nil, "name" => "test todo", "priority" => 20, "created_at" => nil, "updated_at" => nil} - - t.to_h.should eq result - end - it "honors custom primary key" do - s = WebSite.new(name: "Hacker News") - s.custom_id = 3 - s.to_h.should eq({"name" => "Hacker News", "custom_id" => 3}) - end - end - - describe "#to_json" do - it "converts object to json" do - t = Todo.new(name: "test todo", priority: 20) - result = %({"id":null,"name":"test todo","priority":20,"created_at":null,"updated_at":null}) - - t.to_json.should eq result - end - - it "works with collections" do - todos = [ - Todo.new(name: "todo 1", priority: 1), - Todo.new(name: "todo 2", priority: 2), - Todo.new(name: "todo 3", priority: 3), - ] - - collection = JSON.parse todos.to_json - collection[0].should eq({"id" => nil, "name" => "todo 1", "priority" => 1, "created_at" => nil, "updated_at" => nil}) - collection[1].should eq({"id" => nil, "name" => "todo 2", "priority" => 2, "created_at" => nil, "updated_at" => nil}) - collection[2].should eq({"id" => nil, "name" => "todo 3", "priority" => 3, "created_at" => nil, "updated_at" => nil}) - end - - it "honors custom primary key" do - s = WebSite.new(name: "Hacker News") - s.custom_id = 3 - s.to_json.should eq %({"custom_id":3,"name":"Hacker News"}) - end - end - - describe "validating fields" do - context "without a name" do - it "is not valid" do - s = WebSite.new(name: "") - s.valid?.should eq false - s.errors.first.message.should eq "Name cannot be blank" +{% for adapter in GraniteExample::ADAPTERS %} + module {{adapter.capitalize.id}} + describe "{{ adapter.id }} Granite::Base" do + describe "JSON" do + context ".from_json" do + it "can create an object from json" do + json_str = %({"name": "json::anyReview","upvotes": 2, "sentiment": 1.23, "interest": 4.56, "published": true}) + + review = Review.from_json(json_str) + review.name.should eq "json::anyReview" + review.upvotes.should eq 2 + review.sentiment.should eq 1.23.to_f32 + review.interest.should eq 4.56 + review.published.should eq true + review.created_at.should be_nil + end + + it "can create an array of objects from json" do + json_str = %([{"name": "json1","upvotes": 2, "sentiment": 1.23, "interest": 4.56, "published": true},{"name": "json2","upvotes": 0, "sentiment": 5.00, "interest": 6.99, "published": false}]) + + review = Array(Review).from_json(json_str) + review[0].name.should eq "json1" + review[0].upvotes.should eq 2 + review[0].sentiment.should eq 1.23.to_f32 + review[0].interest.should eq 4.56 + review[0].published.should be_true + review[0].created_at.should be_nil + + review[1].name.should eq "json2" + review[1].upvotes.should eq 0 + review[1].sentiment.should eq 5.00.to_f32 + review[1].interest.should eq 6.99 + review[1].published.should be_false + review[1].created_at.should be_nil + end + + it "works with after_initialize" do + model = AfterJSONInit.from_json(%({"name": "after_initialize"})) + + model.name.should eq "after_initialize" + model.priority.should eq 1000 + end + end + + context ".to_json" do + it "emits nil values when told" do + t = TodoEmitNull.new(name: "test todo", priority: 20) + result = %({"id":null,"name":"test todo","priority":20,"created_at":null,"updated_at":null}) + + t.to_json.should eq result + end + + it "does not emit nil values by default" do + t = Todo.new(name: "test todo", priority: 20) + result = %({"name":"test todo","priority":20}) + + t.to_json.should eq result + end + + it "works with array of models" do + todos = [ + Todo.new(name: "todo 1", priority: 1), + Todo.new(name: "todo 2", priority: 2), + Todo.new(name: "todo 3", priority: 3), + ] + + collection = todos.to_json + collection.should eq(%([{"name":"todo 1","priority":1},{"name":"todo 2","priority":2},{"name":"todo 3","priority":3}])) + end + end end - end - context "when name is present" do - it "is valid" do - s = WebSite.new(name: "Hacker News") + describe "#to_h" do + it "convert object to hash" do + t = Todo.new(name: "test todo", priority: 20) + result = {"id" => nil, "name" => "test todo", "priority" => 20, "created_at" => nil, "updated_at" => nil} + + t.to_h.should eq result + end - s.valid?.should eq true - s.errors.empty?.should eq true + it "honors custom primary key" do + s = Item.new(item_name: "Hacker News") + s.item_id = "three" + s.to_h.should eq({"item_name" => "Hacker News", "item_id" => "three"}) + end end end end -end +{% end %} diff --git a/spec/spec_models.cr b/spec/spec_models.cr index 5b01aa98..e16ab438 100644 --- a/spec/spec_models.cr +++ b/spec/spec_models.cr @@ -229,6 +229,37 @@ require "uuid" field articleid : Int64 end + @[JSON::Serializable::Options(emit_nulls: true)] + class TodoEmitNull < Granite::Base + adapter {{ adapter.id }} + table_name todos + + field name : String + field priority : Int32 + timestamps + end + + class Todo < Granite::Base + adapter {{ adapter.id }} + table_name todos + + field name : String + field priority : Int32 + timestamps + end + + class AfterJSONInit < Granite::Base + adapter {{ adapter.id }} + table_name after_json_init + + field name : String + field priority : Int32 + + def after_initialize + @priority = 1000 + end + end + class ArticleViewModel < Granite::Base adapter {{ adapter.id }} @@ -262,6 +293,8 @@ require "uuid" NonAutoCustomPK.migrator.drop_and_create Article.migrator.drop_and_create Comment.migrator.drop_and_create - + Todo.migrator.drop_and_create + TodoEmitNull.migrator.drop_and_create + AfterJSONInit.migrator.drop_and_create end {% end %} diff --git a/src/granite/base.cr b/src/granite/base.cr index a5035dba..92f0ec48 100644 --- a/src/granite/base.cr +++ b/src/granite/base.cr @@ -25,6 +25,8 @@ class Granite::Base include Migrator include Select + include JSON::Serializable + extend Querying extend Transactions::ClassMethods diff --git a/src/granite/callbacks.cr b/src/granite/callbacks.cr index 52a07ed8..50cd84dd 100644 --- a/src/granite/callbacks.cr +++ b/src/granite/callbacks.cr @@ -2,9 +2,10 @@ module Granite::Callbacks class Abort < Exception end - CALLBACK_NAMES = %i(before_save after_save before_create after_create before_update after_update before_destroy after_destroy) + CALLBACK_NAMES = %w(before_save after_save before_create after_create before_update after_update before_destroy after_destroy) - @_current_callback : Symbol? + @[JSON::Field(ignore: true)] + @_current_callback : String? macro included macro inherited diff --git a/src/granite/fields.cr b/src/granite/fields.cr index da277353..3434895e 100644 --- a/src/granite/fields.cr +++ b/src/granite/fields.cr @@ -1,7 +1,7 @@ require "json" module Granite::Fields - alias Type = DB::Any | JSON::Any + alias Type = DB::Any TIME_FORMAT_REGEX = /\d{4,}-\d{2,}-\d{2,}\s\d{2,}:\d{2,}:\d{2,}/ macro included @@ -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 %} property{{suffixes[0].id}} {{name.id}} : Union({{type.id}} | Nil) def {{name.id}}{{suffixes[1].id}} raise {{@type.name.stringify}} + "#" + {{name.stringify}} + " cannot be nil" if @{{name.id}}.nil? @@ -81,22 +84,6 @@ module Granite::Fields return fields end - def to_json(json : JSON::Builder) - json.object do - {% for name, options in FIELDS %} - {% type = options[:type] %} - %field, %value = "{{name.id}}", {{name.id}} - {% if type.id == Time.id %} - json.field %field, %value.try(&.to_s(Granite::DATETIME_FORMAT)) - {% elsif type.id == Slice.id %} - json.field %field, %value.id.try(&.to_s("")) - {% else %} - json.field %field, %value - {% end %} - {% end %} - end - end - def read_attribute(attribute_name : Symbol | String) : DB::Any {% begin %} case attribute_name.to_s @@ -115,10 +102,6 @@ module Granite::Fields end end - def set_attributes(attributes : JSON::Any) - set_attributes(attributes.as_h) - end - def set_attributes(**args) set_attributes(args.to_h) end @@ -139,15 +122,15 @@ module Granite::Fields return @{{_name.id}} = nil if value.nil? {% if type.id == Int32.id %} - @{{_name.id}} = value.is_a?(JSON::Any) ? value.as_i : value.is_a?(String) ? value.to_i32(strict: false) : value.is_a?(Int64) ? value.to_i32 : value.as(Int32) + @{{_name.id}} = value.is_a?(String) ? value.to_i32(strict: false) : value.is_a?(Int64) ? value.to_i32 : value.as(Int32) {% elsif type.id == Int64.id %} - @{{_name.id}} = value.is_a?(JSON::Any) ? value.as_i64 : value.is_a?(String) ? value.to_i64(strict: false) : value.as(Int64) + @{{_name.id}} = value.is_a?(String) ? value.to_i64(strict: false) : value.as(Int64) {% elsif type.id == Float32.id %} - @{{_name.id}} = value.is_a?(JSON::Any) ? value.as_f32 : value.is_a?(String) ? value.to_f32(strict: false) : value.is_a?(Float64) ? value.to_f32 : value.as(Float32) + @{{_name.id}} = value.is_a?(String) ? value.to_f32(strict: false) : value.is_a?(Float64) ? value.to_f32 : value.as(Float32) {% elsif type.id == Float64.id %} - @{{_name.id}} = value.is_a?(JSON::Any) ? value.as_f : value.is_a?(String) ? value.to_f64(strict: false) : value.as(Float64) + @{{_name.id}} = value.is_a?(String) ? value.to_f64(strict: false) : value.as(Float64) {% elsif type.id == Bool.id %} - @{{_name.id}} = value.is_a?(JSON::Any) ? value.as_bool : ["1", "yes", "true", true].includes?(value) + @{{_name.id}} = ["1", "yes", "true", true].includes?(value) {% elsif type.id == Time.id %} if value.is_a?(Time) @{{_name.id}} = value @@ -155,7 +138,7 @@ module Granite::Fields @{{_name.id}} = Time.parse(value.to_s, Granite::DATETIME_FORMAT) end {% else %} - @{{_name.id}} = value.is_a?(JSON::Any) ? value.as_s : value.to_s + @{{_name.id}} = value.to_s {% end %} {% end %} end diff --git a/src/granite/transactions.cr b/src/granite/transactions.cr index 025672df..f0c7760f 100644 --- a/src/granite/transactions.cr +++ b/src/granite/transactions.cr @@ -6,7 +6,7 @@ module Granite::Transactions create(args.to_h) end - def create(args : Hash(Symbol | String, DB::Any) | JSON::Any) + def create(args : Hash(Symbol | String, DB::Any)) instance = new instance.set_attributes(args) instance.save @@ -17,7 +17,7 @@ module Granite::Transactions create!(args.to_h) end - def create!(args : Hash(Symbol | String, DB::Any) | JSON::Any) + def create!(args : Hash(Symbol | String, DB::Any)) instance = create(args) if instance.errors.any? @@ -26,16 +26,6 @@ module Granite::Transactions instance end - - def from_json(args : JSON::Any) - if args.as_a? - args.as_a.map { |a| model = new; model.set_attributes(a); model } - else - model = new - model.set_attributes(args) - model - end - end end macro __process_transactions @@ -176,7 +166,7 @@ module Granite::Transactions update(args.to_h) end - def update(args : Hash(Symbol | String, DB::Any) | JSON::Any) + def update(args : Hash(Symbol | String, DB::Any)) set_attributes(args) save @@ -186,7 +176,7 @@ module Granite::Transactions update!(args.to_h) end - def update!(args : Hash(Symbol | String, DB::Any) | JSON::Any) + def update!(args : Hash(Symbol | String, DB::Any)) set_attributes(args) save! @@ -214,9 +204,11 @@ module Granite::Transactions end # Returns true if this object hasn't been saved yet. + @[JSON::Field(ignore: true)] getter? new_record : Bool = true # Returns true if this object has been destroyed. + @[JSON::Field(ignore: true)] getter? destroyed : Bool = false # Returns true if the record is persisted. diff --git a/src/granite/validators.cr b/src/granite/validators.cr index 851ff35f..475ed793 100644 --- a/src/granite/validators.cr +++ b/src/granite/validators.cr @@ -16,6 +16,7 @@ require "./error" # validate :name, "can't be blank", name_required # ``` module Granite::Validators + @[JSON::Field(ignore: true)] getter errors = [] of Error macro included