├── .gitignore ├── .ruby-version ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib ├── to_json.rb └── to_json │ ├── serializer.rb │ └── version.rb ├── test ├── benchmarks │ ├── .gitignore │ ├── Gemfile │ ├── Gemfile.lock │ └── benchmark.rb ├── minitest_helper.rb └── test_to_json.rb └── to_json.gemspec /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | vendor/bundle 19 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.1.1 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in to_json.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Andrew Hacking 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ToJson 2 | 3 | A performant Ruby JSON Serializer DSL for Oj. ToJson uses the brand new 4 | Oj StringSerializer to provide the fastest performance and lowest 5 | possible memory footprint. 6 | 7 | Why? Because current Ruby JSON serialisers take too long and use too 8 | much memory or can't express **all** valid JSON structures. 9 | 10 | ToJson is ORM and ruby web framework agnostic and designed for serving fast 11 | and flexible JSON APIs. 12 | 13 | ToJson is able to serialize an impressive 1.4 million operations a second 14 | on a 4 core laptop when running multiple ruby processes. 15 | 16 | ## Installation 17 | 18 | Add this line to your application's Gemfile: 19 | 20 | Do this for now: 21 | 22 | gem 'to_json', github: 'ahacking/to_json' 23 | 24 | Eventually: 25 | 26 | gem 'to_json' 27 | 28 | And then execute: 29 | 30 | $ bundle 31 | 32 | Or install it yourself as: 33 | 34 | $ gem install to_json 35 | 36 | ## Design 37 | 38 | ### Goals 39 | 40 | The following goals were the drivers and rationale behind ToJson: 41 | + Ability to express any JSON structure with a simple DSL 42 | + Performance. Existing solutions spend far too much time generating 43 | JSON and this costs money and power. Server hosting is not free and 44 | needlesly burning power and deploying more servers because of rendering 45 | overhehad should be avoided. 46 | 47 | ### Design Choices 48 | 49 | #### Oj for fast JSON encoding 50 | 51 | Leveraging Oj from the start was a deliberate technology choice. The Oj 52 | gem is as close to native C as anyone is likely to get in Ruby. 53 | 54 | I developed a JSON serializer that built temporary Array and Hash structures 55 | to be passed to Oj#dump. This worked well and was already faster than 56 | exisitng JSON serializers. However I thought we could do better... 57 | 58 | #### Streaming architecture 59 | 60 | I had the idea that a streaming serializer would be architecturally 61 | superior (more flexible) and as fast (or faster) and use less memory than 62 | an approach that builds temporary array and hash stuctures. The Oj author 63 | also saw merit in the idea and implemented a new StringSerializer to 64 | support a serialization model where you can push objects in one end and 65 | get JSON out the other without using temporary structures. This proved to 66 | be slightly faster than building temporary arrays and hashes in synthetic 67 | benchmarks but it also results in less memory overhead which will be a 68 | bigger factor in production systems. 69 | 70 | In ToJson, models/objects/values/etc are encoded directly into a buffer with 71 | as close to native C performance as is possible in ruby. 72 | 73 | The architectural benefit of a streaming approach is that it paves the way 74 | for being able to serve and stream massive result sets to sockets and files 75 | using any of the available ruby web frameworks once this feature is made 76 | available in Oj. 77 | 78 | #### Avoid templates 79 | 80 | ToJson does NOT use a Rails ActionView template approach; instead the DSL is 81 | intended to be used directly with a serializer block or within your own serializer 82 | classes. This means ToJson supports all of the expressive power of real Ruby classes 83 | including modules, inheritence, mixins, delegates etc and DOES NOT need to implement 84 | slower and less powerful quasi equivalents in a templating language. 85 | 86 | What does this mean? 87 | + You can easily DRY up your JSON API's 88 | + You can easily version your API's 89 | + You can keep your model helpers and formatters nicely namespaced rather than 90 | global. 91 | + You will not lose the expressiveness and ability to compose and structure 92 | your serialization code. Its 100% ruby, not templates. 93 | 94 | #### Avoid slow language features 95 | 96 | ToJson purposefully does not require Ruby language features like 97 | `method_missing` because it is about 7 times slower than a regular method 98 | call for very minor syntactical advantage. Whilst that alone does not 99 | account for the majority of the speed of ToJson, every bit helps when you 100 | are serializing thosands of objects multiplied by thousands of attributes. 101 | 102 | #### Avoid magic, be explicit, not implicit 103 | 104 | To keep the DSL lean and mean, explicitness was favoured over lots of 105 | ruby meta programming shenanigans. Being explicit about what model 106 | attribute you want encoded in your JSON is consise and allows you to easily 107 | and naturally perform any data presentation formatting without a DSL escape 108 | clause, and more importantly without muddying up your models with presentation 109 | concerns. 110 | 111 | Keeping the DSL simpler also made it faster and as a user of ToJson it 112 | leads to a better structuring and separation of concerns. It also avoids 113 | assuming a 'current model' as some DSL's do which further harms flexibility 114 | and composition. Flexibility is especially important where you need to include 115 | attributes from related/parent models, or collect and aggregrate model data for 116 | presentation in JSON. 117 | 118 | #### ORM agnostic 119 | 120 | Being explicit means we are also ORM agnostic. ToJson does not care 121 | what ORM you are using, or what class the objects being serialized are. 122 | 123 | ## ToJson Alternatives 124 | 125 | Some alternatives to ToJson and primary diferences. 126 | 127 | ### ToJson vs Jbuilder 128 | + DSL relys on method_missing for JSON attribute names 129 | + Integrated with Rails framework 130 | + Fragment caching supported in DSL 131 | + Slower than ToJson 132 | 133 | ### ToJson vs ActiveModel::Serializers 134 | + Currently undergoing unstable changes 135 | + Tied to ActiveModel ORM 136 | + Tied to Rails 137 | + Uses Serializer classes 138 | + Has a serializer generator 139 | + Tries to be declarative 140 | + Very limited control over expression of JSON structure 141 | + Looks up serializers based on the model class. If you care about API versioning 142 | you will realize that this is bad and the controller/presenter MUST decide this. 143 | + Has notion of a 'current model' for the serialization context. 144 | + Uses 'filters' over temporary hashes to control what attributes and related 145 | associations should be serialized. 146 | + Creates a lot of temporary serializer objects 147 | 148 | ### ToJson vs RABL 149 | + A complex (insane) syntax that hinders expressing even simple JSON structures. 150 | + Inteferes with the order of serialized items. 151 | + Many DSL surprises. 152 | + Uses template DSL as opposed to real use Ruby modules and classes for composition. 153 | + Why? 154 | 155 | ### ToJson vs ROAR 156 | + ROAR `extend`s your model instances and invalidates the Ruby method cache which 157 | is a perfomance killer 158 | + If using ROAR's decorator approach to avoid the extend problem you must use lambdas 159 | in the serializer class 160 | + Tries to be declarative 161 | + Very limited control over expression of JSON structure 162 | + ROAR provides bi-directional serialization 163 | + Explicit support for JSON+HAL, but see ToJson example below 164 | 165 | ### ToJson vs JSONBuilder 166 | + JSONBuilder is very slow (but not as slow as Jsonify) 167 | + DSL relys on method_missing for JSON attribute names 168 | 169 | ### ToJson vs Jsonify 170 | + Jsonify is the slowest JSON serialization option I am aware of 171 | + DSL relys on method_missing for the JSON attribute names 172 | + Jsonify uses a builder model as opposed to serializer classes 173 | + Jsonify supports rails template integration through a companion gem 174 | + Jsonify provides Tilt based view integration 175 | 176 | 177 | ## Benchmarks 178 | 179 | You are encouraged to verify benchmarks for yourself as follows: 180 | 181 | ``` 182 | $ cd test/benchmark 183 | $ bundle install 184 | $ ./benchmark.rb 185 | ``` 186 | 187 | On a Intel(R) Core(TM) i7-3610QM CPU @ 2.30GHz (Ivy Bridge): 188 | 189 | ``` 190 | Serialize 500,000 objects separately: 191 | user system total real 192 | ToJson (class) - simple 1.330000 0.000000 1.330000 ( 1.324826) 193 | ToJson (class) - parallel 0.000000 0.010000 42.880000 ( 5.459818) 194 | (8000000 ops) 195 | ToJson (class) - complex 5.000000 0.000000 5.000000 ( 5.012555) 196 | ToJson (block) - simple 5.190000 0.000000 5.190000 ( 5.191536) 197 | ToJson (block) - complex 9.390000 0.000000 9.390000 ( 9.390543) 198 | Jbuilder - simple 6.180000 0.000000 6.180000 ( 6.182140) 199 | Jbuilder - complex 22.600000 2.240000 24.840000 ( 24.861366) 200 | ``` 201 | 202 | The real time used is the important figure. As can be seen ToJson using a 203 | class based serializer is 4.7 times faster than the fastest alternative. 204 | 205 | The benchmark invokes to_json each time as would be the case serving separate 206 | requests. Invoked this way is worst case vs a single large result, however 207 | it is able to serialize 377 thousand address objects per second per CPU, and 208 | just under 100 thousand complex objects a second per CPU. 209 | 210 | On a multi-core machine running the benchmark in 32 separate ruby processes 211 | to_json gives impressive throughput. ToJson is able able to serialize 212 | 1.4 million individual address serializations per second on a 4 core Intel 213 | laptop (i7-3610QM CPU @ 2.30GHz Ivy Bridge). Performance is linear and in 214 | real applications disk and network I/O will be the limiting factor. 215 | 216 | 217 | Old benchmark results for posterity: 218 | 219 | ``` 220 | JSONBuilder original benchmark (500000 complex objects): 221 | user system total real 222 | ToJson (class) 7.950000 2.080000 10.030000 ( 10.045144) 223 | ToJson (block) 14.340000 2.130000 16.470000 ( 16.471651) 224 | Jbuilder 29.180000 2.600000 31.780000 ( 31.790766) 225 | JSONBuilder 56.230000 2.820000 59.050000 ( 59.083252) 226 | jsonify 156.310000 2.700000 159.010000 (159.088787) 227 | ``` 228 | 229 | TODO. Add benchmarks for ActiveModel::Serializers, ROAR and RABL benchmarks. 230 | This will require a different JSON structure which they are all 231 | capable of producing. 232 | 233 | 234 | ## Usage 235 | 236 | ## General Invocation with block 237 | 238 | ```ruby 239 | # args are optional 240 | ToJson::Serializer.json!(args...) do |args...| 241 | # DSL goes here, callers methods, helpers, instance variables and constants are all in scope 242 | end 243 | end 244 | ``` 245 | 246 | ### Invocation from Rails controller, respond_with and block 247 | 248 | ```ruby 249 | def index 250 | @post = Post.all 251 | # the rails responder will call to_json on the ToJson object 252 | respond_with ToJson::Serializer.encode! do 253 | # DSL goes here, contoller methods, helpers, instance variables and 254 | # constants are all in scope 255 | end 256 | end 257 | ``` 258 | 259 | ### Invocation from Rails API controller, render with block (better) 260 | 261 | ```ruby 262 | def index 263 | @post = Post.all 264 | # generate the json and pass it to render for sending to the client 265 | render json: ToJson::Serializer.json! do 266 | # DSL goes here, contoller methods, helpers, instance variables and 267 | # constants are all in scope 268 | end 269 | end 270 | ``` 271 | 272 | ### Invocation from Rails API controller with custom serializer class (recommended) 273 | 274 | ```ruby 275 | def index 276 | # just pass the collection (instead of the controller) to better support 277 | # serializing Posts in different contexts and controllers. @foo is evil 278 | render json: PostsSerializer.json!(Post.all) 279 | end 280 | ``` 281 | 282 | ### JSON Objects 283 | 284 | The `put` method is used to serialize named object values and 285 | create arbitrarily nested objects. 286 | 287 | All values will be serialized according to Oj processing rules. 288 | 289 | #### Example creating an object with named values: 290 | 291 | ```ruby 292 | put :title, @post.title 293 | put :body, @post.body 294 | ``` 295 | 296 | #### Example with fields helper 297 | 298 | ```ruby 299 | put_fields @post, :title, :body 300 | ``` 301 | 302 | #### Example with fields helper and key mapping. 303 | 304 | The DSL accepts array pairs, hashes, arrays containing any 305 | mix of array or hash pairs. 306 | 307 | The following examples are all equivalent and map 'title' to 'the_tile' 308 | and 'created_at' to 'post_date' and leave 'body' as is. 309 | 310 | ```ruby 311 | put_fields @post, [:title, :the_title], :body, [:created_at, :post_date] 312 | put_fields @post, [[:title, :the_title], :body, [:created_at, :post_date]] 313 | put_fields @post, {title: :the_title, body: nil, created_at: :post_date} 314 | put_fields @post, [:title, :the_title], :body, {:created_at => :post_date} 315 | put_fields @post, {title: :the_title}, :body, {created_at: :post_date} 316 | ``` 317 | 318 | #### Example with fields helper with condition. 319 | 320 | There are helpers to serialize object fields conditionally. 321 | 322 | ```ruby 323 | put_fields_unless_blank @post, :title: :body 324 | put_fields_unless_nil @post, :title: :body 325 | put_fields_unless :large?, @post, :title: :body 326 | put_fields_if :allowed, @post, :title: :body 327 | ``` 328 | 329 | #### Example of serializing a single field 330 | 331 | There are single field equivalents of the multiple field helpers. these 332 | take an optional mapping key and just like put they accept a block. 333 | 334 | ```ruby 335 | put_field @post, :title 336 | put_field @post, :title, :the_title 337 | put_field_unless_blank @post, :title, :the_title 338 | put_field_unless_nil @post, :title, :the_title 339 | put_field_unless :large? @post, :body 340 | put_field_if :allowed? @post, :body 341 | ``` 342 | 343 | #### Example creating a nested object 344 | 345 | The long way: 346 | 347 | ```ruby 348 | put :post do 349 | put :title, @post.title 350 | put :body, @post.body 351 | end 352 | 353 | Using field helper: 354 | 355 | ```ruby 356 | put :post do put_fields @post, :title :body end 357 | ``` 358 | 359 | ### Example of a named object literal 360 | 361 | The hash value under 'author' will be serialized directly by Oj. 362 | 363 | ```ruby 364 | put :author, {name: 'Fred', email: 'fred@example.com', age: 27} 365 | ``` 366 | 367 | ### Example of an object literal 368 | 369 | The hash value will be serialized by Oj. 370 | 371 | ```ruby 372 | value {name: 'Fred', email: 'fred@example.com', age: 27} 373 | ``` 374 | 375 | #### Example creating a nested object with argument passed to block 376 | 377 | ```ruby 378 | put :latest_post, current_user.posts.order(:created_at: :desc).first do |post| 379 | put_fields post, :title, :body 380 | end 381 | ``` 382 | 383 | ### JSON Arrays 384 | 385 | Arrays provide aggregation in JSON and are created with the `array` method. Array 386 | elements can be created through: 387 | + literal value(s) passed to `array` without a block 388 | + evaluating blocks over the argument passed to array (similar to `each_with_index`) 389 | + evaluating a block with no argument 390 | 391 | Within the array block, array elements can be created using `value`, however this is 392 | called implicitly for you when using `put` or `array` inside the array block. 393 | 394 | ### Example of an array literal 395 | 396 | The literal array value will be passed to Oj for serialization. 397 | 398 | ```ruby 399 | array ['Fred', 'fred@example.com', 27] 400 | ``` 401 | 402 | ### Example of an array collection 403 | 404 | The @posts collection will be passed to Oj for serialization. 405 | 406 | ```ruby 407 | array @posts 408 | ``` 409 | 410 | ### Example of array with block for custom object serialization 411 | 412 | ```ruby 413 | array @posts do |post| 414 | # calling put/put_* inside an array does an implicit 'value' call 415 | # placing all named values into a single object 416 | put_fields post, :title, post.body 417 | end 418 | ``` 419 | 420 | ### Example of array with block and item index for custom object serialization 421 | 422 | ```ruby 423 | array @posts do |post, index| 424 | put_fields post, :title, post.body 425 | put :position, index 426 | end 427 | ``` 428 | 429 | ### Example collecting post author emails into a single array. 430 | 431 | Each post item will be processed and the email addresses of the author 432 | serialized. 433 | 434 | ```ruby 435 | array @posts do |post| 436 | @post.author.emails.each do |email| 437 | value email.address 438 | end 439 | end 440 | ``` 441 | 442 | ### Example creating array element values explicitly 443 | 444 | The following example will an array containing 3 elements. 445 | 446 | ```ruby 447 | array do 448 | value 'one' 449 | value 2 450 | value do 451 | put label: 'three' 452 | end 453 | end 454 | ``` 455 | 456 | ### Example creating array with a nested object and nested collection 457 | 458 | ```ruby 459 | array do 460 | value do 461 | put :total_entries, @posts.total_entries 462 | put :total_pages, @posts.total_pages 463 | end 464 | array @posts do 465 | put :title, post.title 466 | put :body, post.body 467 | end 468 | end 469 | ``` 470 | 471 | ### Example creating a paged collection as per the HAL specification: 472 | 473 | ```ruby 474 | put :meta do 475 | put_fields @posts, :total_entries, :total_pages 476 | end 477 | put :collection do 478 | array @posts do |post| put_fields post, :title, :body end 479 | end 480 | put :_links do 481 | put :self { put :href, url_for(page: @posts.current_page) } 482 | put :first { put :href, url_for(page: 1) } 483 | put :previous { @posts.current_page <= 1 ? nil : put :href, url_for(page: @posts.current_page-1) } 484 | put :next { current_page_num >= @posts.total_pages ? nil : put :href, url_for(page: @posts.current_page+1) } 485 | put :last { put :href, url_for(page: @posts.total_pages) } 486 | end 487 | ``` 488 | 489 | ### Example of nested arrays, and dynamic array value generation: 490 | 491 | ```ruby 492 | array do 493 | # this nested array is a single value in the outer array 494 | array do 495 | value 'a' 496 | value 'b' 497 | value 'b' 498 | end 499 | # this nested array is a single value in the outer array 500 | array (1..3) 501 | (1..4).each do |count| 502 | # generate 'count' values in the nested array 503 | count.times { value "item #{count}" } 504 | end 505 | end 506 | end 507 | ``` 508 | 509 | ### Example of defining and using a helper 510 | 511 | ```ruby 512 | def fullname(*names) 513 | names.join(' ') 514 | end 515 | 516 | put :author, fullname(@post.author.first_name, @post.author.last_name) 517 | ``` 518 | 519 | ### Example of class based serialization and composition: 520 | 521 | 522 | ```ruby 523 | # A Post model serializer, using ::ToJson::Serializer inheritance 524 | class PostSerializer < ::ToJson::Serializer 525 | include PostSerialization 526 | 527 | # override the serialize method and use the ToJson DSL 528 | # any arguments passed to encode! or json! are passed into serialize 529 | def serialize(model) 530 | put_post_nested model 531 | end 532 | end 533 | 534 | # A Post collection serializer using include ToJson::Serialize approach 535 | class PostsSerializer 536 | include PostSerialization 537 | 538 | def serialize(collection) 539 | put_posts collection 540 | end 541 | end 542 | 543 | # define a module so we can mixin Post model serialization concerns 544 | anywhere and avoid temporary serializer objects for collection items 545 | module PostSerialization 546 | include ::ToJson::Serialize 547 | 548 | # formatting helper 549 | def fullname(*names) 550 | names.join(' ') 551 | end 552 | 553 | def put_post(post) 554 | put :title, post.title 555 | put :body, post.body 556 | put :author, fullname(post.author.first_name, post.author.last_name) 557 | put :comments, CommentsSerializer.new(post.comments) 558 | end 559 | 560 | def put_post_nested(post) 561 | put :post do 562 | put_post(post) 563 | end 564 | end 565 | 566 | def serialize_posts(posts) 567 | put :meta do 568 | put :total_entries, posts.total_entries 569 | put :total_pages, posts.total_pages 570 | end 571 | put :collection, posts do |post| 572 | put_post post 573 | end 574 | 575 | end 576 | end 577 | ``` 578 | 579 | ## ToDo 580 | 581 | + Tests and more tests. 582 | + API Documentation. 583 | 584 | ## Contributing 585 | 586 | 1. Fork it 587 | 2. Create your feature branch (`git checkout -b my-new-feature`) 588 | 3. Commit your changes (`git commit -am 'Add some feature'`) 589 | 4. Push to the branch (`git push origin my-new-feature`) 590 | 5. Create new Pull Request 591 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | end 7 | 8 | task :default => :test 9 | -------------------------------------------------------------------------------- /lib/to_json.rb: -------------------------------------------------------------------------------- 1 | require "to_json/version" 2 | require "to_json/serializer" 3 | 4 | module ToJson 5 | class InvalidStructure < StandardError; end 6 | end 7 | -------------------------------------------------------------------------------- /lib/to_json/serializer.rb: -------------------------------------------------------------------------------- 1 | require 'oj' 2 | 3 | module ToJson 4 | 5 | # 6 | # Serialize module for inclusion into classes 7 | # 8 | module Serialize 9 | 10 | def self.included(base) 11 | base.extend ClassMethods 12 | end 13 | 14 | module ClassMethods 15 | def encode!(*args, &block) 16 | new.encode!(*args, &block) 17 | end 18 | 19 | def json!(*args, &block) 20 | new.json!(*args, &block) 21 | end 22 | end 23 | 24 | # 25 | # instance methods 26 | # 27 | 28 | def json!(*args, &block) 29 | encode!(*args, &block) 30 | to_json 31 | end 32 | 33 | def encode!(*args, &block) 34 | @_scope = args[0] 35 | @_obj_depth = 0 36 | if @_oj 37 | @_oj.reset 38 | else 39 | @_oj = Oj::StringWriter.new(:mode => :compat) 40 | end 41 | serialize(*args, &block) 42 | @_oj.pop_all 43 | self 44 | end 45 | 46 | def serialize(*args, &block) 47 | # the following line gets the callers 'self' so we can copy instance vars and delegate to it 48 | @_scope = ::Kernel.eval 'self', block.binding 49 | _copy_ivars @_scope 50 | instance_exec(*args, &block) 51 | end 52 | 53 | def to_json 54 | @_oj.to_s 55 | end 56 | 57 | def to_s 58 | to_json 59 | end 60 | 61 | def scope 62 | @_scope 63 | end 64 | 65 | # Put an array 66 | def array(*args, &block) 67 | if block 68 | @_oj.push_array @_key # open the array (with or without key as required) 69 | @_key = nil # clear key 70 | obj_depth = @_obj_depth # save object depth 71 | if args.count == 0 # if no collection just invoke block 72 | @_obj_depth = 0 # reset object depth 73 | block.call # yield to the block 74 | @_oj.pop if @_obj_depth > 0 # automatically close nested objects 75 | else 76 | items = args.count == 1 ? Array(args[0]) : args # use array item or implicit literal array 77 | if block.arity == 2 78 | items.each_with_index do |item,index| # serialize each item using the block 79 | @_obj_depth = 0 # reset object depth to zero for array elements 80 | block.call item, index # yield item and index to the block 81 | @_oj.pop if @_obj_depth > 0 # automatically close nested objects 82 | end 83 | else 84 | items.each do |item| # serialize each item using the block 85 | @_obj_depth = 0 # reset object depth to zero for array elements 86 | block.call item # yield item to the block 87 | @_oj.pop if @_obj_depth > 0 # automatically close nested objects 88 | end 89 | end 90 | end 91 | @_oj.pop # close the array 92 | @_obj_depth = obj_depth # restore object depth 93 | else 94 | args = args[0] || [] if args.count == 1 # collection argument, treat nil as empty 95 | # all other cases args is implicit array 96 | @_oj.push_value args, @_key # serialize collection using Oj with or without key 97 | end 98 | end 99 | 100 | # Start a JSON array. 101 | # 102 | # There is normally no need to use this primitive, however it can be useful if the content 103 | # of the array is not known in advance and where the array block form cannot be used. 104 | # 105 | # @example 106 | # open_array 107 | # value 1 108 | # value 2 109 | # close 110 | def open_array 111 | @_oj.push_array @_key # open the array (with or without key as required) 112 | @_key = nil # clear key 113 | @_obj_depth += 1 114 | end 115 | 116 | # Start a JSON object 117 | # 118 | # There is normally no need to use this primitive, however it can be useful if the content 119 | # of the object is not known in advance and where the put with block methods cannot be used. 120 | # 121 | # @example 122 | # open 'post' 123 | # put 'id', 1 124 | # put 'title', 'Some title' 125 | # put 'content', 'Amazing post content.' 126 | # close 127 | # 128 | # @example 129 | # open 'post_ids' 130 | # open array 131 | # value 1 132 | # value 2 133 | # close 134 | # close 135 | # 136 | def open(key=nil) 137 | if @_key || @_obj_depth == 0 # existing saved key or root/array? 138 | @_obj_depth += 1 # increase object depth 139 | @_oj.push_object @_key # push start of object (@_key nil for root/array) 140 | end 141 | @_key = key # stash/clear key 142 | end 143 | 144 | # Close the array/object 145 | def close 146 | raise EncodingError, 'Already Closed. Invalid to_json DSL detected.' if @_obj_depth < 1 147 | @_obj_depth -= 1 148 | @_oj.pop # close the array or object 149 | end 150 | 151 | # Put a value 152 | def value(value=nil, &block) 153 | put! nil, value, &block # serialize the value 154 | end 155 | 156 | # Put a named value 157 | def put(key, value=nil, &block) 158 | put! key.to_s, value, &block # serialize the key and value 159 | end 160 | 161 | # 162 | # field helpers 163 | # 164 | 165 | # Put an object field unless the value is nil 166 | def put_field_unless_nil(obj, field, as=nil, &block) 167 | put_field_unless :nil?, obj, field, as, &block 168 | end 169 | 170 | # Put an object field unless the value is blank 171 | def put_field_unless_blank(obj, field, as=nil, &block) 172 | put_field_unless :blank?, obj, field, as, &block 173 | end 174 | 175 | # Put an object field unless value condtion is true 176 | def put_field_if(condition, obj, field, as=nil, &block) 177 | value = obj.send(field) 178 | put!((as || field).to_s, value, &block) if value.send(condition) 179 | end 180 | 181 | # Put an object field if value condition is true 182 | def put_field_unless(condition, obj, field, as=nil, &block) 183 | value = obj.send(field) 184 | put!((as || field).to_s, value, &block) unless value.send(condition) 185 | end 186 | 187 | # Put an object field 188 | def put_field(obj, field, as=nil, &block) 189 | put!((as || field).to_s, obj.send(field), &block) 190 | end 191 | 192 | # Put specified object fields with optional mapping. 193 | # 194 | # The DSL accepts array pairs, hashes, arrays containing any 195 | # mix of array or hash pairs. 196 | # 197 | # The following examples are all equivalent and map 'title' to 'the_tile' 198 | # and 'created_at' to 'post_date' and leave 'body' as is. 199 | # 200 | # put_fields @post, [:title, :the_title], :body, [:created_at, :post_date] 201 | # put_fields @post, [[:title, :the_title], :body, [:created_at, :post_date]] 202 | # put_fields @post, {title: :the_title, body: nil, created_at: :post_date} 203 | # put_fields @post, [:title, :the_title], :body, {:created_at => :post_date} 204 | # put_fields @post, {title: :the_title}, :body, {created_at: :post_date} 205 | def put_fields(obj, *keys) 206 | keys.each do |key, as| # could be any enumerable, type may be nil 207 | if key.is_a? Hash 208 | put_fields obj, key # recurse to expand hash 209 | else 210 | put!((as || key).to_s, obj.send(key)) 211 | end 212 | end 213 | end 214 | 215 | # Put specified object fields unless blank. 216 | # 217 | # The field keys can be mapped as per put_fields 218 | def put_fields_unless_blank(obj, *keys) 219 | put_fields_unless :blank?, obj, *keys 220 | end 221 | 222 | # Put specified object fields unless nil. 223 | # 224 | # The field keys can be mapped as per put_fields 225 | def put_fields_unless_nil(obj, *keys) 226 | put_fields_unless :nil?, obj, *keys 227 | end 228 | 229 | # Put specified object unless the field value condition is true. 230 | # 231 | # The field keys can be mapped as per put_fields 232 | def put_fields_unless(condition, obj, *keys) 233 | keys.each do |key, as| # could be any enumerable, type may be nil 234 | if key.is_a? Hash 235 | put_fields_unless condition, obj, key # recurse to expand hash 236 | else 237 | put_field_unless condition, obj, key, as 238 | end 239 | end 240 | end 241 | 242 | # Put specified object if field value condition is true. 243 | # 244 | # The field keys can be mapped as per put_fields. 245 | def put_fields_if(condition, obj, *keys) 246 | keys.each do |key, as| # could be any enumerable, type may be nil 247 | if key.is_a? Hash 248 | put_fields_if condition, obj, key # recurse to expand hash 249 | else 250 | put_field_if condition, obj, key, as 251 | end 252 | end 253 | end 254 | 255 | # 256 | # put object primitive 257 | # 258 | def put!(key=nil, value=nil, &block) 259 | if @_key || @_obj_depth == 0 # existing saved key or root/array? 260 | if key # nesting a key forces object creation! 261 | @_obj_depth += 1 # increase object depth 262 | @_oj.push_object @_key # push start of object (@_key nil for root/array) 263 | else 264 | key = @_key # unstash saved key 265 | end 266 | end 267 | 268 | if block 269 | @_key = key # stash current key for block call 270 | obj_depth = @_obj_depth # save current object depth to detect object creation 271 | block.call(value) # yield value to the block 272 | @_oj.pop if @_obj_depth > obj_depth # automatically close any nested object created by block 273 | @_obj_depth = obj_depth # restore object depth 274 | else 275 | @_oj.push_value value, key # serialize value using Oj with or without key 276 | end 277 | @_key = nil # ensure key is cleared 278 | end 279 | 280 | def method_missing(method, *args, &block) 281 | # delegate to the scope 282 | @_scope.send method, *args, &block 283 | end 284 | 285 | def const_missing(name) 286 | # delegate to the scope 287 | @_scope.class.const_get name 288 | end 289 | 290 | private 291 | 292 | def _copy_ivars(object) 293 | vars = object.instance_variables - self.instance_variables 294 | vars.each { |v| instance_variable_set v, object.instance_variable_get(v) } 295 | end 296 | end 297 | 298 | # 299 | # Serializer class for inheritance 300 | # 301 | class Serializer 302 | include Serialize 303 | end 304 | end 305 | -------------------------------------------------------------------------------- /lib/to_json/version.rb: -------------------------------------------------------------------------------- 1 | module ToJson 2 | VERSION = "0.0.4" 3 | end 4 | -------------------------------------------------------------------------------- /test/benchmarks/.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | -------------------------------------------------------------------------------- /test/benchmarks/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gem 'oj' 3 | gem 'oj_mimic_json' 4 | gem 'to_json', path: '../..' 5 | gem 'jbuilder' 6 | gem 'json_builder' 7 | gem 'jsonify' 8 | -------------------------------------------------------------------------------- /test/benchmarks/Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: ../.. 3 | specs: 4 | to_json (0.0.4) 5 | oj (>= 2.9.4) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activesupport (4.1.1) 11 | i18n (~> 0.6, >= 0.6.9) 12 | json (~> 1.7, >= 1.7.7) 13 | minitest (~> 5.1) 14 | thread_safe (~> 0.1) 15 | tzinfo (~> 1.1) 16 | i18n (0.6.9) 17 | jbuilder (2.0.7) 18 | activesupport (>= 3.0.0, < 5) 19 | multi_json (~> 1.2) 20 | json (1.8.1) 21 | json_builder (3.1.7) 22 | activesupport (>= 2.0.0) 23 | json 24 | jsonify (0.4.1) 25 | multi_json (~> 1.3) 26 | minitest (5.3.4) 27 | multi_json (1.10.1) 28 | oj (2.9.4) 29 | oj_mimic_json (1.0.1) 30 | thread_safe (0.3.4) 31 | tzinfo (1.2.1) 32 | thread_safe (~> 0.1) 33 | 34 | PLATFORMS 35 | ruby 36 | 37 | DEPENDENCIES 38 | jbuilder 39 | json_builder 40 | jsonify 41 | oj 42 | oj_mimic_json 43 | to_json! 44 | -------------------------------------------------------------------------------- /test/benchmarks/benchmark.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'rubygems' 3 | require 'bundler/setup' 4 | 5 | require 'benchmark' 6 | require 'oj' 7 | require 'oj_mimic_json' 8 | require 'to_json' 9 | require 'jbuilder' 10 | require 'json_builder' 11 | require 'jsonify' 12 | 13 | enough = 500_000 14 | parallel = 16 15 | puts "Serialize 500,000 objects separately:" 16 | birthday = Time.local(1991, 9, 14) 17 | 18 | Benchmark.bm(15) do |b| 19 | 20 | class AddressSerializer < ToJson::Serializer 21 | def serialize 22 | put :address, "1143 1st Ave" 23 | put :address2, "Apt 200" 24 | put :city, "New York" 25 | put :state, "New York" 26 | put :zip, 10065 27 | end 28 | end 29 | 30 | b.report('ToJson (class) - simple') do 31 | s = AddressSerializer.new 32 | enough.times { 33 | s.json! 34 | } 35 | end 36 | 37 | b.report("ToJson (class) - parallel (#{parallel*enough} ops)") do 38 | s = AddressSerializer.new 39 | parallel.times do 40 | Process.fork do 41 | enough.times { s.json! } 42 | end 43 | end 44 | Process.waitall 45 | end 46 | 47 | b.report('ToJson (class) - complex') do 48 | class BenchmarkSerializer < ToJson::Serializer 49 | def serialize(birthday) 50 | put :name, "Garrett Bjerkhoel" 51 | put :birthday, birthday 52 | put :street do 53 | put :address, "1143 1st Ave" 54 | put :address2, "Apt 200" 55 | put :city, "New York" 56 | put :state, "New York" 57 | put :zip, 10065 58 | end 59 | put :skills do 60 | put :ruby, true 61 | put :asp, false 62 | put :php, true 63 | put :mysql, true 64 | put :mongodb, true 65 | put :haproxy, true 66 | put :marathon, false 67 | end 68 | put :single_skills, ['ruby', 'php', 'mysql', 'mongodb', 'haproxy'] 69 | put :booleans, [true, true, false, nil] 70 | end 71 | end 72 | s = BenchmarkSerializer.new 73 | enough.times { 74 | s.json!(birthday) 75 | } 76 | end 77 | 78 | b.report('ToJson (block) - simple') do 79 | s = ToJson::Serializer.new 80 | enough.times { 81 | s.json! { 82 | put :address, "1143 1st Ave" 83 | put :address2, "Apt 200" 84 | put :city, "New York" 85 | put :state, "New York" 86 | put :zip, 10065 87 | } 88 | } 89 | end 90 | 91 | b.report('ToJson (block) - complex') do 92 | s = ToJson::Serializer.new 93 | enough.times { 94 | s.json! { 95 | put :name, "Garrett Bjerkhoel" 96 | put :birthday, birthday 97 | put :street do 98 | put :address, "1143 1st Ave" 99 | put :address2, "Apt 200" 100 | put :city, "New York" 101 | put :state, "New York" 102 | put :zip, 10065 103 | end 104 | put :skills do 105 | put :ruby, true 106 | put :asp, false 107 | put :php, true 108 | put :mysql, true 109 | put :mongodb, true 110 | put :haproxy, true 111 | put :marathon, false 112 | end 113 | put :single_skills, ['ruby', 'php', 'mysql', 'mongodb', 'haproxy'] 114 | put :booleans, [true, true, false, nil] 115 | } 116 | } 117 | end 118 | 119 | b.report('Jbuilder - simple') do 120 | enough.times { 121 | Jbuilder.encode { |json| 122 | json.address "1143 1st Ave" 123 | json.address2 "Apt 200" 124 | json.city "New York" 125 | json.state "New York" 126 | json.zip 10065 127 | } 128 | } 129 | end 130 | b.report('Jbuilder - complex') do 131 | enough.times { 132 | Jbuilder.encode { |json| 133 | json.name "Garrett Bjerkhoel" 134 | json.birthday Time.local(1991, 9, 14) 135 | json.street do 136 | json.address "1143 1st Ave" 137 | json.address2 "Apt 200" 138 | json.city "New York" 139 | json.state "New York" 140 | json.zip 10065 141 | end 142 | json.skills do 143 | json.ruby true 144 | json.asp false 145 | json.php true 146 | json.mysql true 147 | json.mongodb true 148 | json.haproxy true 149 | json.marathon false 150 | end 151 | json.single_skills ['ruby', 'php', 'mysql', 'mongodb', 'haproxy'] 152 | json.booleans [true, true, false, nil] 153 | } 154 | } 155 | end 156 | 157 | b.report('JSONBuilder - complex') do 158 | enough.times { 159 | JSONBuilder::Compiler.generate { 160 | name "Garrett Bjerkhoel" 161 | birthday birthday 162 | street do 163 | address "1143 1st Ave" 164 | address2 "Apt 200" 165 | city "New York" 166 | state "New York" 167 | zip 10065 168 | end 169 | skills do 170 | ruby true 171 | asp false 172 | php true 173 | mysql true 174 | mongodb true 175 | haproxy true 176 | marathon false 177 | end 178 | single_skills ['ruby', 'php', 'mysql', 'mongodb', 'haproxy'] 179 | booleans [true, true, false, nil] 180 | } 181 | } 182 | end 183 | 184 | b.report('jsonify - complex') do 185 | enough.times { 186 | json = Jsonify::Builder.new 187 | json.name "Garrett Bjerkhoel" 188 | json.birthday birthday 189 | json.street do 190 | json.address "1143 1st Ave" 191 | json.address2 "Apt 200" 192 | json.city "New York" 193 | json.state "New York" 194 | json.zip 10065 195 | end 196 | json.skills do 197 | json.ruby true 198 | json.asp false 199 | json.php true 200 | json.mysql true 201 | json.mongodb true 202 | json.haproxy true 203 | json.marathon false 204 | end 205 | json.single_skills ['ruby', 'php', 'mysql', 'mongodb', 'haproxy'] 206 | json.booleans [true, true, false, nil] 207 | json.compile! 208 | } 209 | end 210 | end 211 | -------------------------------------------------------------------------------- /test/minitest_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'to_json' 3 | 4 | require 'minitest/autorun' 5 | -------------------------------------------------------------------------------- /test/test_to_json.rb: -------------------------------------------------------------------------------- 1 | require 'minitest_helper' 2 | 3 | class TestToJson < MiniTest::Unit::TestCase 4 | def test_that_it_has_a_version_number 5 | refute_nil ::ToJson::VERSION 6 | end 7 | 8 | def test_it_does_something_useful 9 | assert false 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /to_json.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'to_json/version' 5 | require 'date' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "to_json" 9 | spec.version = ::ToJson::VERSION 10 | spec.authors = ["Andrew Hacking"] 11 | spec.date = Date.today.to_s 12 | spec.email = ["ahacking@gmail.com"] 13 | spec.homepage = "https://github.com/ahacking/to_json" 14 | spec.summary = %q{A pragmatic DSL for fast JSON serialization} 15 | spec.description = spec.summary 16 | spec.license = "MIT" 17 | 18 | spec.files = `git ls-files`.split($/) 19 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 20 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 21 | spec.require_paths = ["lib"] 22 | 23 | spec.add_dependency 'oj', '>= 2.9.4' 24 | 25 | spec.add_development_dependency "bundler", "~> 1.3" 26 | spec.add_development_dependency "rake" 27 | spec.add_development_dependency "minitest" 28 | end 29 | --------------------------------------------------------------------------------