diff --git a/.gitignore b/.gitignore index b04a8c8..8c4389d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ /pkg/ /spec/reports/ /tmp/ +/benchmark/flamegraphs/ +/gemfiles/*.lock # rspec failure tracking .rspec_status diff --git a/.rubocop.yml b/.rubocop.yml index 2699690..20cebe3 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -40,6 +40,21 @@ Layout/AccessModifierIndentation: Layout/CaseIndentation: EnforcedStyle: 'end' +Layout/EndAlignment: + Enabled: false + +Layout/IndentationWidth: + Enabled: false + +Layout/ElseAlignment: + Enabled: false + +Naming/MethodParameterName: + Enabled: false + +Style/GuardClause: + Enabled: false + # Disabled to allow the outdented comment style Layout/CommentIndentation: Enabled: false @@ -59,3 +74,8 @@ Metrics/ClassLength: Naming/PredicateName: Enabled: false +Style/Alias: + Enabled: false +AllCops: + NewCops: enable + SuggestExtensions: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 3445c43..d34d33f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## [Oj Serializers 2.0.0 (2023-03-27)](https://github.com/ElMassimo/oj_serializers/pull/9) + +### Features ✨ + +- Improved performance (20% to 40% faster than v1) +- Added `render_as_hash` to efficiently build a Hash from the serializer +- `transform_keys :camelize`: a built-in setting to convert keys, in a way that does not affect runtime performance +- `sort_keys_by :name`: allows to sort the response alphabetically, without affecting runtime performance +- `render` shortcut, unifying `one` and `many` +- `attribute` as an easier approach to define serializer attributes + +### Breaking Changes + +Since returning a `Hash` is more convenient than returning a `Oj::StringWriter`, and performance is comparable, `default_format :hash` is now the default. + +The previous APIs will still be available as `one_as_json` and `many_as_json`, as well as `default_format :json` to make the library work like in version 1. + ## Oj Serializers 1.0.2 (2023-03-01) ## * [fix: avoid freezing `ALLOWED_INSTANCE_VARIABLES`](https://github.com/ElMassimo/oj_serializers/commit/ade0302) diff --git a/Gemfile b/Gemfile index 975fbc0..d2199db 100644 --- a/Gemfile +++ b/Gemfile @@ -2,9 +2,28 @@ source 'https://rubygems.org' -# Specify your gem's dependencies in oj_serializers.gemspec +git_source(:github) { |repo| "https://github.com/#{repo}.git" } + gemspec group :development do gem 'rubocop' end + +group :development, :test do + gem 'active_model_serializers', '~> 0.8' + gem 'alba' + gem 'benchmark-ips' + gem 'benchmark-memory' + gem 'blueprinter', '~> 0.8' + gem 'memory_profiler' + gem 'mongoid' + gem 'panko_serializer' + gem 'pry-byebug', '~> 3.9' + gem 'rails' unless ENV['NO_RAILS'] + gem 'rake', '~> 13.0' + gem 'rspec-rails', '~> 4.0' + gem 'simplecov', '< 0.18' + gem 'singed' + gem 'sqlite3' +end diff --git a/Gemfile.lock b/Gemfile.lock index 49f86d0..da2550e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,12 +1,29 @@ PATH remote: . specs: - oj_serializers (1.0.2) + oj_serializers (2.0.0) oj (>= 3.14.0) GEM remote: https://rubygems.org/ specs: + actioncable (6.0.3.4) + actionpack (= 6.0.3.4) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + actionmailbox (6.0.3.4) + actionpack (= 6.0.3.4) + activejob (= 6.0.3.4) + activerecord (= 6.0.3.4) + activestorage (= 6.0.3.4) + activesupport (= 6.0.3.4) + mail (>= 2.7.1) + actionmailer (6.0.3.4) + actionpack (= 6.0.3.4) + actionview (= 6.0.3.4) + activejob (= 6.0.3.4) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) actionpack (6.0.3.4) actionview (= 6.0.3.4) activesupport (= 6.0.3.4) @@ -14,6 +31,12 @@ GEM rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) + actiontext (6.0.3.4) + actionpack (= 6.0.3.4) + activerecord (= 6.0.3.4) + activestorage (= 6.0.3.4) + activesupport (= 6.0.3.4) + nokogiri (>= 1.8.5) actionview (6.0.3.4) activesupport (= 6.0.3.4) builder (~> 3.1) @@ -25,30 +48,46 @@ GEM activemodel (>= 4.1, < 6.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) + activejob (6.0.3.4) + activesupport (= 6.0.3.4) + globalid (>= 0.3.6) activemodel (6.0.3.4) activesupport (= 6.0.3.4) activerecord (6.0.3.4) activemodel (= 6.0.3.4) activesupport (= 6.0.3.4) + activestorage (6.0.3.4) + actionpack (= 6.0.3.4) + activejob (= 6.0.3.4) + activerecord (= 6.0.3.4) + marcel (~> 0.3.1) activesupport (6.0.3.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 0.7, < 2) minitest (~> 5.1) tzinfo (~> 1.1) zeitwerk (~> 2.2, >= 2.2.2) - ast (2.4.1) + alba (2.2.0) + ast (2.4.2) benchmark-ips (2.8.3) + benchmark-memory (0.1.2) + memory_profiler (~> 0.9) + blueprinter (0.25.3) bson (4.11.1) builder (3.2.4) byebug (11.1.3) case_transform (0.2) activesupport coderay (1.1.3) + colorize (0.8.1) concurrent-ruby (1.1.7) crass (1.0.6) + date (3.3.3) diff-lcs (1.4.4) docile (1.3.2) erubi (1.9.0) + globalid (1.1.0) + activesupport (>= 5.0) i18n (1.8.5) concurrent-ruby (~> 1.0) json (2.3.1) @@ -56,21 +95,45 @@ GEM loofah (2.7.0) crass (~> 1.0.2) nokogiri (>= 1.5.9) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (0.3.3) + mimemagic (~> 0.3.2) memory_profiler (0.9.14) method_source (1.0.0) + mimemagic (0.3.10) + nokogiri (~> 1) + rake + mini_mime (1.1.2) minitest (5.18.0) mongo (2.13.1) bson (>= 4.8.2, < 5.0.0) mongoid (7.1.4) activemodel (>= 5.1, < 6.1) mongo (>= 2.7.0, < 3.0.0) + net-imap (0.3.4) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.1) + timeout + net-smtp (0.3.3) + net-protocol + nio4r (2.5.8) nokogiri (1.14.2-arm64-darwin) racc (~> 1.4) nokogiri (1.14.2-x86_64-linux) racc (~> 1.4) oj (3.14.2) - parallel (1.19.2) - parser (2.7.1.4) + panko_serializer (0.7.9) + activesupport + oj (> 3.11.0, < 4.0.0) + parallel (1.22.1) + parser (3.2.1.1) ast (~> 2.4.1) pry (0.13.1) coderay (~> 1.1) @@ -82,6 +145,21 @@ GEM rack (2.2.3) rack-test (1.1.0) rack (>= 1.0, < 3) + rails (6.0.3.4) + actioncable (= 6.0.3.4) + actionmailbox (= 6.0.3.4) + actionmailer (= 6.0.3.4) + actionpack (= 6.0.3.4) + actiontext (= 6.0.3.4) + actionview (= 6.0.3.4) + activejob (= 6.0.3.4) + activemodel (= 6.0.3.4) + activerecord (= 6.0.3.4) + activestorage (= 6.0.3.4) + activesupport (= 6.0.3.4) + bundler (>= 1.3.0) + railties (= 6.0.3.4) + sprockets-rails (>= 2.0.0) rails-dom-testing (2.0.3) activesupport (>= 4.2.0) nokogiri (>= 1.6) @@ -93,10 +171,10 @@ GEM method_source rake (>= 0.8.7) thor (>= 0.20.3, < 2.0) - rainbow (3.0.0) + rainbow (3.1.1) rake (13.0.6) - regexp_parser (1.7.1) - rexml (3.2.4) + regexp_parser (2.7.0) + rexml (3.2.5) rspec-core (3.9.3) rspec-support (~> 3.9.3) rspec-expectations (3.9.3) @@ -114,29 +192,45 @@ GEM rspec-mocks (~> 3.9) rspec-support (~> 3.9) rspec-support (3.9.4) - rubocop (0.86.0) + rubocop (1.48.1) + json (~> 2.3) parallel (~> 1.10) - parser (>= 2.7.0.1) + parser (>= 3.2.0.0) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 1.7) - rexml - rubocop-ast (>= 0.0.3, < 1.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.26.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 2.0) - rubocop-ast (0.1.0) - parser (>= 2.7.0.1) - ruby-progressbar (1.10.1) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.27.0) + parser (>= 3.2.1.0) + ruby-progressbar (1.13.0) simplecov (0.17.1) docile (~> 1.1) json (>= 1.8, < 3) simplecov-html (~> 0.10.0) simplecov-html (0.10.2) + singed (0.1.1) + colorize + stackprof + sprockets (4.1.1) + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + sprockets-rails (3.4.2) + actionpack (>= 5.2) + activesupport (>= 5.2) + sprockets (>= 3.0.0) sqlite3 (1.4.2) + stackprof (0.2.24) thor (1.0.1) thread_safe (0.3.6) + timeout (0.3.2) tzinfo (1.2.7) thread_safe (~> 0.1) - unicode-display_width (1.7.0) + unicode-display_width (2.4.2) + websocket-driver (0.7.5) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) zeitwerk (2.4.1) PLATFORMS @@ -144,19 +238,22 @@ PLATFORMS x86_64-linux DEPENDENCIES - actionpack (>= 4.0) active_model_serializers (~> 0.8) - activerecord + alba benchmark-ips + benchmark-memory + blueprinter (~> 0.8) memory_profiler mongoid oj_serializers! + panko_serializer pry-byebug (~> 3.9) - railties (>= 4.0) + rails rake (~> 13.0) rspec-rails (~> 4.0) rubocop simplecov (< 0.18) + singed sqlite3 BUNDLED WITH diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index a0c9e6a..b93440d 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -2,15 +2,16 @@ [request_store]: https://github.com/steveklabnik/request_store [request_store_rails]: https://github.com/ElMassimo/request_store_rails -[readme]: https://github.com/ElMassimo/oj_serializers/blob/master/README.md +[readme]: https://github.com/ElMassimo/oj_serializers/blob/main/README.md +[attributes dsl]: https://github.com/ElMassimo/oj_serializers/blob/main/README.md#attributes-dsl- [oj]: https://github.com/ohler55/oj [ams]: https://github.com/rails-api/active_model_serializers [jsonapi]: https://github.com/jsonapi-serializer/jsonapi-serializer [panko]: https://github.com/panko-serializer/panko_serializer [benchmarks]: https://github.com/ElMassimo/oj_serializers/tree/master/benchmarks -[raw_benchmarks]: https://github.com/ElMassimo/oj_serializers/blob/master/benchmarks/document_benchmark.rb -[migration guide]: https://github.com/ElMassimo/oj_serializers/blob/master/MIGRATION_GUIDE.md +[raw_benchmarks]: https://github.com/ElMassimo/oj_serializers/blob/main/benchmarks/document_benchmark.rb +[migration guide]: https://github.com/ElMassimo/oj_serializers/blob/main/MIGRATION_GUIDE.md [raw_json]: https://github.com/ohler55/oj/issues/542 [trailing_commas]: https://maximomussini.com/posts/trailing-commas/ @@ -44,9 +45,19 @@ render json: { ### Attributes -Have in mind that unlike in Active Model Serializers, `attributes` in `Oj::Serializer` will _not_ take into account methods defined in the serializer. +If you read the [Attributes DSL] section, you might have noticed that you need +to _explicitly_ tell when a method in the serializer should be used by +specifying it with `attribute`. -Specially in the beginning, you can replace `attributes` with `ams_attributes` to preserve the same behavior. +This makes the serializers more predictable and more maintainable, but it can +make it challenging to migrate from `active_model_serializers`. + +Specially in the beginning, you can replace `attributes` with `ams_attributes` +to preserve the same behavior. + +`ams_attributes` works like `attributes` in `active_model_serializers`: by +calling a method in the serializer if defined, or calling +`read_attribute_for_serialization` in the model. ```ruby class AlbumSerializer < ActiveModel::Serializer @@ -96,16 +107,16 @@ class AlbumSerializer < Oj::Serializer has_many :songs, serializer: SongSerializer - attribute \ + attribute if: -> { album.released? } def release album.release_date.strftime('%B %d, %Y') - end, if: -> { album.released? } + end end ``` -The shorthand syntax for serializer attributes might not be very palatable at -first, but having the entire definition in one place makes it a lot easier to -follow, specially in large serializers. +The shorthand syntax for serializer attributes might seem odd at first, but it +makes it a lot easier to differentiate helper methods from attributes, +especially in large serializers. ## Migrate gradually, one at a time diff --git a/README.md b/README.md index fa6dcc4..3c817ab 100644 --- a/README.md +++ b/README.md @@ -6,29 +6,33 @@ Oj Serializers Maintainability Test Coverage Gem Version -License +License

-JSON serializers for Ruby, built on top of the powerful [`oj`][oj] library. +Faster JSON serializers for Ruby, built on top of the powerful [`oj`][oj] library. [oj]: https://github.com/ohler55/oj [mongoid]: https://github.com/mongodb/mongoid [ams]: https://github.com/rails-api/active_model_serializers [jsonapi]: https://github.com/jsonapi-serializer/jsonapi-serializer [panko]: https://github.com/panko-serializer/panko_serializer +[blueprinter]: https://github.com/procore/blueprinter [benchmarks]: https://github.com/ElMassimo/oj_serializers/tree/master/benchmarks -[raw_benchmarks]: https://github.com/ElMassimo/oj_serializers/blob/master/benchmarks/document_benchmark.rb -[sugar]: https://github.com/ElMassimo/oj_serializers/blob/master/lib/oj_serializers/sugar.rb#L14 -[migration guide]: https://github.com/ElMassimo/oj_serializers/blob/master/MIGRATION_GUIDE.md +[raw_benchmarks]: https://github.com/ElMassimo/oj_serializers/blob/main/benchmarks/document_benchmark.rb +[sugar]: https://github.com/ElMassimo/oj_serializers/blob/main/lib/oj_serializers/sugar.rb#L14 +[migration guide]: https://github.com/ElMassimo/oj_serializers/blob/main/MIGRATION_GUIDE.md [design]: https://github.com/ElMassimo/oj_serializers#design- [raw_json]: https://github.com/ohler55/oj/issues/542 [trailing_commas]: https://maximomussini.com/posts/trailing-commas/ +[render dsl]: https://github.com/ElMassimo/oj_serializers#render-dsl- +[sorbet]: https://sorbet.org/ +[Discussion]: https://github.com/ElMassimo/oj_serializers/discussions ## Why? 🤔 -[`ActiveModel::Serializer`][ams] has a nice DSL, but it allocates many objects leading -to memory bloat, time spent on GC, and lower performance. +[`ActiveModel::Serializer`][ams] has a nice DSL, but it allocates many objects +leading to memory bloat, time spent on GC, and lower performance. `Oj::Serializer` provides a similar API, with [better performance][benchmarks]. @@ -36,12 +40,11 @@ Learn more about [how this library achieves its performance][design]. ## Features ⚡️ -- Declaration syntax similar to Active Model Serializers -- Reduced memory allocation and [improved performance][benchmarks] +- Declaration syntax [similar to Active Model Serializers][migration guide] +- Reduced [memory allocation][benchmarks] and [improved performance][benchmarks] - Support for `has_one` and `has_many`, compose with `flat_one` - Useful development checks to avoid typos and mistakes - Integrates nicely with Rails controllers -- Caching ## Installation 💿 @@ -58,13 +61,13 @@ And then run: ## Usage 🚀 You can define a serializer by subclassing `Oj::Serializer`, and specify which -attributes should be serialized to JSON. +attributes should be serialized. ```ruby class AlbumSerializer < Oj::Serializer attributes :name, :genres - attribute \ + attr def release album.release_date.strftime('%B %d, %Y') end @@ -76,129 +79,84 @@ end
Example Output -```json +```ruby { - "name": "Abraxas", - "genres": [ + name: "Abraxas", + genres: [ "Pyschodelic Rock", "Blues Rock", "Jazz Fusion", - "Latin Rock" + "Latin Rock", ], - "release": "September 23, 1970", - "songs": [ + release: "September 23, 1970", + songs: [ { - "track": 1, - "name": "Sing Winds, Crying Beasts", - "composers": [ - "Michael Carabello" - ] + track: 1, + name: "Sing Winds, Crying Beasts", + composers: ["Michael Carabello"], }, { - "track": 2, - "name": "Black Magic Woman / Gypsy Queen", - "composers": [ - "Peter Green", - "Gábor Szabó" - ] + track: 2, + name: "Black Magic Woman / Gypsy Queen", + composers: ["Peter Green", "Gábor Szabó"], }, { - "track": 3, - "name": "Oye como va", - "composers": [ - "Tito Puente" - ] + track: 3, + name: "Oye como va", + composers: ["Tito Puente"], }, { - "track": 4, - "name": "Incident at Neshabur", - "composers": [ - "Alberto Gianquinto", - "Carlos Santana" - ] + track: 4, + name: "Incident at Neshabur", + composers: ["Alberto Gianquinto", "Carlos Santana"], }, { - "track": 5, - "name": "Se acabó", - "composers": [ - "José Areas" - ] + track: 5, + name: "Se acabó", + composers: ["José Areas"], }, { - "track": 6, - "name": "Mother's Daughter", - "composers": [ - "Gregg Rolie" - ] + track: 6, + name: "Mother's Daughter", + composers: ["Gregg Rolie"], }, { - "track": 7, - "name": "Samba pa ti", - "composers": [ - "Santana" - ] + track: 7, + name: "Samba pa ti", + composers: ["Santana"], }, { - "track": 8, - "name": "Hope You're Feeling Better", - "composers": [ - "Rolie" - ] + track: 8, + name: "Hope You're Feeling Better", + composers: ["Rolie"], }, { - "track": 9, - "name": "El Nicoya", - "composers": [ - "Areas" - ] - } - ] + track: 9, + name: "El Nicoya", + composers: ["Areas"], + }, + ], } ```
-
- -To use the serializer, the recommended approach is: +You can then use your new serializer to render an object or collection: ```ruby class AlbumsController < ApplicationController def show - album = Album.find(params[:id]) render json: AlbumSerializer.one(album) end def index - albums = Album.all render json: { albums: AlbumSerializer.many(albums) } end end ``` -If you are using Rails you can also use something closer to Active Model Serializers by adding [`sugar`][sugar]: - -```ruby -require 'oj_serializers/sugar' - -class AlbumsController < ApplicationController - def show - album = Album.find(params[:id]) - render json: album, serializer: AlbumSerializer - end - - def index - albums = Album.all - render json: albums, each_serializer: AlbumSerializer, root: :albums - end -end -``` - -It's recommended to create your own `BaseSerializer` class in order to easily -add custom extensions, specially when migrating from `active_model_serializers`. - -## Render DSL 🛠 +## Rendering 🖨 -In order to efficiently reuse the instances, serializers can't be instantiated directly. Use `one` and `many` to serialize objects or enumerables: +Use `one` to serialize objects, and `many` to serialize enumerables: ```ruby render json: { @@ -207,173 +165,212 @@ render json: { } ``` -You can use these serializers inside arrays, hashes, or even inside `ActiveModel::Serializer` by using a method in the serializer. - -Follow [this discussion][raw_json] to find out more about [the `raw_json` extensions][raw_json] that made this high level of interoperability possible. - -## Attributes DSL 🛠 +Serializers can be rendered arrays, hashes, or even inside `ActiveModel::Serializer` +by using a method in the serializer, making it very easy to combine with other +libraries and migrate incrementally. -Attributes methods can be used to define which model attributes should be serialized -to JSON. Each method provides a different strategy to obtain the values to serialize. +You can use `render` as a shortcut for `one` and `many`, but it might be less readable: -The internal design is simple and extensible, so creating new strategies requires very little code. -Please open an issue if you need help 😃 +```ruby +render json: { + favorite_album: AlbumSerializer.render(album), + purchased_albums: AlbumSerializer.render(albums), +} +``` -### `attributes` +## Attributes DSL 🪄 -Obtains the attribute value by calling a method in the object being serialized. +Specify which attributes should be rendered by calling a method in the object to serialize. ```ruby class PlayerSerializer < Oj::Serializer - attributes :full_name + attributes :first_name, :last_name, :full_name end ``` -Have in mind that unlike Active Model Serializers, it will _not_ take into -account methods defined in the serializer. Being explicit about where the -attribute is coming from makes the serializers easier to understand and more -maintainable. - -### `serializer_attributes` - -Obtains the attribute value by calling a method defined in the serializer. - - -You may call [`serializer_attributes`](https://github.com/ElMassimo/oj_serializers/blob/master/spec/support/serializers/song_serializer.rb#L13-L15) or use the `attribute` inline syntax: +You can serialize custom values by specifying that a method is an `attribute`: ```ruby class PlayerSerializer < Oj::Serializer - attribute \ - def full_name + attribute :name do "#{player.first_name} #{player.last_name}" end -end -``` - -Instance methods can access the object by the serializer name without the -`Serializer` suffix, `player` in the example above, or directly as `@object`. - -You can customize this by using [`object_as`](https://github.com/ElMassimo/oj_serializers#using-a-different-alias-for-the-internal-object). -### `ams_attributes` 🐌 + # or -Works like `attributes` in Active Model Serializers, by calling a method in the serializer if defined, or calling `read_attribute_for_serialization` in the model. - -```ruby -class AlbumSerializer < Oj::Serializer - ams_attributes :name, :release - - def release - album.release_date.strftime('%B %d, %Y') + attribute + def name + "#{player.first_name} #{player.last_name}" end end ``` -Should only be used when migrating from Active Model Serializers, as it's slower and can create confusion. +> **Note** +> +> In this example, `player` was inferred from `PlayerSerializer`. +> +> You can customize this by using [`object_as`](#using-a-different-alias-for-the-internal-object). -Instead, use `attributes` for model methods, and the inline `attribute` for serializer attributes. Being explicit makes serializers easier to understand, and to maintain. -Please refer to the [migration guide] for more information. +### Associations 🔗 -### `hash_attributes` 🚀 +Use `has_one` to serialize individual objects, and `has_many` to serialize a collection. -Very convenient when serializing Hash-like structures, this strategy uses the `[]` operator. +You must specificy which serializer to use with the `serializer` option. ```ruby -class PersonSerializer < Oj::Serializer - hash_attributes 'first_name', :last_name +class SongSerializer < Oj::Serializer + has_one :album, serializer: AlbumSerializer + has_many :composers, serializer: ComposerSerializer end - -PersonSerializer.one('first_name' => 'Mary', :middle_name => 'Jane', :last_name => 'Watson') -# {"first_name":"Mary","last_name":"Watson"} ``` -### `mongo_attributes` 🚀 - -Reads data directly from `attributes` in a [Mongoid] document. - -By skipping type casting, coercion, and defaults, it [achieves the best performance][raw_benchmarks]. - -Although there are some downsides, depending on how consistent your schema is, -and which kind of consumer the API has, it can be really powerful. +Specify a different value for the association by providing a block: ```ruby -class AlbumSerializer < Oj::Serializer - mongo_attributes :id, :name +class SongSerializer < Oj::Serializer + has_one :album, serializer: AlbumSerializer do + Album.find_by(song_ids: song.id) + end end ``` -## Associations DSL 🛠 +In case you need to pass options, you can call the serializer manually: -Use `has_one` to serialize individual objects, and `has_many` to serialize a collection. +```ruby +class SongSerializer < Oj::Serializer + attribute :album do + AlbumSerializer.one(song.album, for_song: song) + end +end +``` -The value for the association is obtained from a serializer method if defined, or by calling the method in the object being serialized. +### Aliasing or renaming attributes ↔️ -You must specificy which serializer to use with the `serializer` option. +You can pass `as` when defining an attribute or association to serialize it +using a different key: ```ruby class SongSerializer < Oj::Serializer - has_one :album, serializer: AlbumSerializer - has_many :composers, serializer: ComposerSerializer + has_one :album, as: :first_release, serializer: AlbumSerializer - # You can also compose serializers using `flat_one`. - flat_one :song, serializer: SongMetadataSerializer + attributes title: {as: :name} + + # or as a shortcut + attributes title: :name end ``` -The associations DSL is more concise and achieves better performance, so prefer to use it instead of manually definining attributes: +### Conditional attributes ❔ + +You can render attributes and associations conditionally by using `:if`. ```ruby -class SongSerializer < SongMetadataSerializer - attribute \ - def album - AlbumSerializer.one(song.album) - end +class PlayerSerializer < Oj::Serializer + attributes :first_name, :last_name, if: -> { player.display_name? } - attribute \ - def composers - ComposerSerializer.many(song.composers) - end + has_one :album, serializer: AlbumSerializer, if: -> { player.album } end ``` -## Other DSL 🛠 +This is useful in cases where you don't want to `null` values to be in the response. + +## Advanced Usage 🧙‍♂️ ### Using a different alias for the internal object -You can use `object_as` to create an alias for the serialized object to access it from instance methods: +In most cases, the default alias for the `object` will be convenient enough. + +However, if you would like to specify it manually, use `object_as`: ```ruby class DiscographySerializer < Oj::Serializer object_as :artist # Now we can use `artist` instead of `object` or `discography`. + attribute def latest_albums artist.albums.desc(:year) end end ``` -### Rendering an attribute conditionally +### Identifier attributes -All the attributes and association methods can take an `if` option to render conditionally. +The `identifier` method allows you to only include an identifier if the record +or document has been persisted. ```ruby class AlbumSerializer < Oj::Serializer - mongo_attributes :release_date, if: -> { album.released? } + identifier + + # or if it's a different field + identifier :uuid +end +``` + +Additionally, identifier fields are always rendered first, even when sorting +fields alphabetically. + +### Transforming attribute keys 🗝 + +When serialized data will be consumed from a client language that has different +naming conventions, it can be convenient to transform keys accordingly. + +For example, when rendering an API to be consumed from the browser via JavaScript, +where properties are traditionally named using camel case. + +Use `transform_keys` to handle that conversion. + +```ruby +class BaseSerializer < Oj::Serializer + transform_keys :camelize + + # shortcut for + transform_keys -> (key) { key.to_s.camelize(:lower) } +end +``` + +This has no performance impact, as keys will be transformed at load time. + +### Sorting attributes 📶 - has_many :songs, serializer: SongSerializer, if: -> { album.songs.any? } +By default attributes are rendered in the order they are defined. - # You can achieve the same by manually defining a method: - def include_songs? - album.songs.any? +If you would like to sort attributes alphabetically, you can specify it at a +serializer level: + +```ruby +class BaseSerializer < Oj::Serializer + sort_attributes_by :name # or a Proc +end +``` + +This has no performance impact, as attributes will be sorted at load time. + +### Path helpers 🛣 + +In case you need to access path helpers in your serializers, you can use the +following: + +```ruby +class BaseSerializer < Oj::Serializer + include Rails.application.routes.url_helpers + + def default_url_options + Rails.application.routes.default_url_options end end ``` -### Memoization & Local State +One slight variation that might make it easier to maintain in the long term is +to use a separate singleton service to provide the url helpers and options, and +make it available as `urls`. -Serializers are designed to be stateless so that an instanced can be reused, but sometimes it's convenient to store intermediate calculations. +### Memoization & local state + +Serializers are designed to be stateless so that an instanced can be reused, but +sometimes it's convenient to store intermediate calculations. Use `memo` for memoization and storing temporary information. @@ -381,7 +378,7 @@ Use `memo` for memoization and storing temporary information. class DownloadSerializer < Oj::Serializer attributes :filename, :size - attribute \ + attribute def progress "#{ last_event&.progress || 0 }%" end @@ -396,9 +393,50 @@ private end ``` +### `hash_attributes` 🚀 + +Very convenient when serializing Hash-like structures, this strategy uses the `[]` operator. + +```ruby +class PersonSerializer < Oj::Serializer + hash_attributes 'first_name', :last_name +end + +PersonSerializer.one('first_name' => 'Mary', :middle_name => 'Jane', :last_name => 'Watson') +# {first_name: "Mary", last_name: "Watson"} +``` + +### `mongo_attributes` 🚀 + +Reads data directly from `attributes` in a [Mongoid] document. + +By skipping type casting, coercion, and defaults, it [achieves the best performance][raw_benchmarks]. + +Although there are some downsides, depending on how consistent your schema is, +and which kind of consumer the API has, it can be really powerful. + +```ruby +class AlbumSerializer < Oj::Serializer + mongo_attributes :id, :name +end +``` + ### Caching 📦 -Use `cached` to leverage key-based caching, which calls `cache_key` in the object. You can also provide a lambda to `cached_with_key` to define a custom key: +Usually rendering is so fast that __turning caching on can be slower__. + +However, in cases of deeply nested structures, unpredictable query patterns, or +methods that take a long time to run, caching can improve performance. + +To enable caching, use `cached`, which calls `cache_key` in the object: + +```ruby +class CachedUserSerializer < UserSerializer + cached +end +``` + +You can also provide a lambda to `cached_with_key` to define a custom key: ```ruby class CachedUserSerializer < UserSerializer @@ -408,33 +446,164 @@ class CachedUserSerializer < UserSerializer end ``` -It will leverage `fetch_multi` when serializing a collection with `many` or `has_many`, to minimize the amount of round trips needed to read and write all items to cache. This works specially well if your cache store also supports `write_multi`. +It will leverage `fetch_multi` when serializing a collection with `many` or +`has_many`, to minimize the amount of round trips needed to read and write all +items to cache. + +This works specially well if your cache store also supports `write_multi`. + +### Writing to JSON + +In some corner cases it might be faster to serialize using a `Oj::StringWriter`, +which you can access by using `one_as_json` and `many_as_json`. + +Alternatively, you can toggle this mode at a serializer level by using +`default_format :json`, or configure it globally from your base serializer: + +```ruby +class BaseSerializer < Oj::Serializer + default_format :json +end +``` + +This will change the default shortcuts (`render`, `one`, `one_if`, and `many`), +so that the serializer writes directly to JSON instead of returning a Hash. + +> **Note** +> +> This was the default behavior in `oj_serializers` v1, but was replaced with +`default_format :hash` in v2. + +
+ Example Output + +```json +{ + "name": "Abraxas", + "genres": [ + "Pyschodelic Rock", + "Blues Rock", + "Jazz Fusion", + "Latin Rock" + ], + "release": "September 23, 1970", + "songs": [ + { + "track": 1, + "name": "Sing Winds, Crying Beasts", + "composers": [ + "Michael Carabello" + ] + }, + { + "track": 2, + "name": "Black Magic Woman / Gypsy Queen", + "composers": [ + "Peter Green", + "Gábor Szabó" + ] + }, + { + "track": 3, + "name": "Oye como va", + "composers": [ + "Tito Puente" + ] + }, + { + "track": 4, + "name": "Incident at Neshabur", + "composers": [ + "Alberto Gianquinto", + "Carlos Santana" + ] + }, + { + "track": 5, + "name": "Se acabó", + "composers": [ + "José Areas" + ] + }, + { + "track": 6, + "name": "Mother's Daughter", + "composers": [ + "Gregg Rolie" + ] + }, + { + "track": 7, + "name": "Samba pa ti", + "composers": [ + "Santana" + ] + }, + { + "track": 8, + "name": "Hope You're Feeling Better", + "composers": [ + "Rolie" + ] + }, + { + "track": 9, + "name": "El Nicoya", + "composers": [ + "Areas" + ] + } + ] +} +``` +
-Usually serialization happens so fast that __turning caching on can be slower__. Always benchmark to make sure it's worth it, and use caching only for time-consuming or deeply nested structures. +Even when using this mode, you can still use rendered values inside arrays, +hashes, and other serializers, thanks to [the `raw_json` extensions][raw_json]. ## Design 📐 Unlike `ActiveModel::Serializer`, which builds a Hash that then gets encoded to -JSON, this implementation uses `Oj::StringWriter` to write JSON directly, +JSON, this implementation can use `Oj::StringWriter` to write JSON directly, greatly reducing the overhead of allocating and garbage collecting the hashes. It also allocates a single instance per serializer class, which makes it easy to use, while keeping memory usage under control. +The internal design is simple and extensible, and because the library is written +in Ruby, creating new serialization strategies requires very little code. +Please open a [Discussion] if you need help 😃 + ### Comparison with other libraries `ActiveModel::Serializer` instantiates one serializer object per item to be serialized. -Other libraries such as [`jsonapi-serializer`][jsonapi] evaluate serializers in the context of -a `class` instead of an `instance` of a class. Although it is efficient in terms -of memory usage, the downside is that you can't use instance methods or local -memoization, and any mixins must be applied to the class itself. +Other libraries such as [`blueprinter`][blueprinter] [`jsonapi-serializer`][jsonapi] +evaluate serializers in the context of a `class` instead of an `instance` of a class. +The downside is that you can't use instance methods or local memoization, and any +mixins must be applied to the class itself. [`panko-serializer`][panko] also uses `Oj::StringWriter`, but it has the big downside of having to own the entire render tree. Putting a serializer inside a Hash or an Active Model Serializer and serializing that to JSON doesn't work, making a gradual migration harder to achieve. Also, it's optimized for Active Record but I needed good Mongoid support. `Oj::Serializer` combines some of these ideas, by using instances, but reusing them to avoid object allocations. Serializing 10,000 items instantiates a single serializer. Unlike `panko-serializer`, it doesn't suffer from [double encoding problems](https://panko.dev/docs/response-bag) so it's easier to use. -As a result, migrating from `active_model_serializers` is relatively straightforward because instance methods, inheritance, and mixins work as usual. +Follow [this discussion][raw_json] to find out more about [the `raw_json` extensions][raw_json] that made this high level of interoperability possible. + +As a result, migrating from `active_model_serializers` is relatively +straightforward because instance methods, inheritance, and mixins work as usual. + +### Benchmarks 📊 + +This library includes some [benchmarks] to compare performance with similar libraries. + +See [this pull request](https://github.com/ElMassimo/oj_serializers/pull/9) for a quick comparison, +or check the CI to see the latest results. + +### Migrating from other libraries + +Please refer to the [migration guide] for a full discussion of the compatibility +modes available to make it easier to migrate from `active_model_serializers` and +similar libraries. ## Formatting 📏 diff --git a/benchmarks/album_serializer_benchmark.rb b/benchmarks/album_serializer_benchmark.rb index 5db8b4b..ea6f898 100644 --- a/benchmarks/album_serializer_benchmark.rb +++ b/benchmarks/album_serializer_benchmark.rb @@ -2,44 +2,91 @@ require 'benchmark_helper' -require 'rails' -require 'active_support/json' -require 'oj_serializers/compat' -require 'support/serializers/active_model_serializer' -require 'support/serializers/album_serializer' -require 'support/serializers/legacy_serializers' -require 'support/models/album' - -RSpec.describe AlbumSerializer, category: :benchmark do +RSpec.describe 'AlbumSerializer', :benchmark do context 'albums' do - let(:album) { Album.abraxas } - let!(:albums) do - 100.times.map { Album.abraxas } + before(:all) do + album = Album.abraxas + output = AlbumSerializer.one(album, special: true).to_json + expect(output).to eq LegacyAlbumSerializer.new(album, special: true).to_json + expect(output).to eq AlbumBlueprint.render(album, special: true) + expect(JSON.parse(output)).to eq JSON.parse AlbumPanko.new(context: { special: true }).serialize_to_json(album) + expect(JSON.parse(output)).to eq JSON.parse AlbumAlba.new(album, params: { special: true }).serialize end it 'serializing a model' do - expect(AlbumSerializer.one(album, special: true).to_json).to eq LegacyAlbumSerializer.new(album, special: true).to_json + album = Album.abraxas Benchmark.ips do |x| x.config(time: 5, warmup: 2) + x.report('oj_serializers as_hash') do + Oj.dump AlbumSerializer.one_as_hash(album) + end x.report('oj_serializers') do - Oj.dump AlbumSerializer.one(album) + Oj.dump AlbumSerializer.one_as_json(album) + end + x.report('panko') do + AlbumPanko.new.serialize_to_json(album) + end + x.report('blueprinter') do + AlbumBlueprint.render(album) end x.report('active_model_serializers') do Oj.dump LegacyAlbumSerializer.new(album) end + x.report('alba') do + AlbumAlba.new(album).serialize + end x.compare! end end it 'serializing a collection' do + albums = 100.times.map { Album.abraxas } + Benchmark.ips do |x| + x.config(time: 5, warmup: 2) + x.report('oj_serializers as_hash') do + Oj.dump AlbumSerializer.many_as_hash(albums) + end + x.report('oj_serializers') do + Oj.dump AlbumSerializer.many_as_json(albums) + end + x.report('panko') do + Panko::ArraySerializer.new(albums, each_serializer: AlbumPanko).to_json + end + x.report('blueprinter') do + AlbumBlueprint.render(albums) + end + x.report('active_model_serializers') do + Oj.dump(albums.map { |album| LegacyAlbumSerializer.new(album) }) + end + x.report('alba') do + AlbumAlba.new(albums).serialize + end + x.compare! + end + end + + it 'serializing a large collection' do + albums = 1000.times.map { Album.abraxas } Benchmark.ips do |x| x.config(time: 5, warmup: 2) + x.report('oj_serializers as_hash') do + Oj.dump AlbumSerializer.many_as_hash(albums) + end x.report('oj_serializers') do - Oj.dump AlbumSerializer.many(albums) + Oj.dump AlbumSerializer.many_as_json(albums) + end + x.report('panko') do + Panko::ArraySerializer.new(albums, each_serializer: AlbumPanko).to_json + end + x.report('blueprinter') do + AlbumBlueprint.render(albums) end x.report('active_model_serializers') do Oj.dump(albums.map { |album| LegacyAlbumSerializer.new(album) }) end + x.report('alba') do + AlbumAlba.new(albums).serialize + end x.compare! end end diff --git a/benchmarks/document_benchmark.rb b/benchmarks/document_benchmark.rb index 60897d9..fb80954 100644 --- a/benchmarks/document_benchmark.rb +++ b/benchmarks/document_benchmark.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true require 'benchmark_helper' -require 'support/models/album' -RSpec.describe 'Document Accessors', category: :benchmark do +RSpec.describe 'Document Accessors', :benchmark do let(:album) { Album.abraxas } it 'getters performance' do diff --git a/benchmarks/game_serializer_benchmark.rb b/benchmarks/game_serializer_benchmark.rb new file mode 100644 index 0000000..be68cba --- /dev/null +++ b/benchmarks/game_serializer_benchmark.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'benchmark_helper' +require 'support/models/sql' + +class PlayerPanko < Panko::Serializer + attributes :id, :first_name, :last_name, :full_name + + def id + object.id if object.persisted? + end + + def full_name + object.full_name + end +end + +# NOTE: This example is quite contrived. Finding good test cases is as hard as +# finding good names. +class ScoresPanko < Panko::Serializer + attributes :high_score, :score +end + +class GamePanko < Panko::Serializer + attributes :id, :name + + has_one :scores, serializer: ScoresPanko + + has_one :best_player, serializer: PlayerPanko + has_many :players, serializer: PlayerPanko + + def id + object.id if object.persisted? + end +end + +Game.prepend(Module.new { + def scores + self + end +}) + +RSpec.describe 'GameSerializer', :benchmark do + context 'albums' do + it 'serializing a model' do + game = Game.example + + Benchmark.ips do |x| + x.config(time: 5, warmup: 2) + x.report('oj_serializers as_hash') do + Oj.dump GameSerializer.one_as_hash(game) + end + x.report('oj_serializers') do + Oj.dump GameSerializer.one_as_json(game) + end + x.report('panko') do + GamePanko.new.serialize_to_json(game) + end + x.compare! + end + end + end +end diff --git a/benchmarks/memory_usage_benchmark.rb b/benchmarks/memory_usage_benchmark.rb index a202413..420364f 100644 --- a/benchmarks/memory_usage_benchmark.rb +++ b/benchmarks/memory_usage_benchmark.rb @@ -1,39 +1,56 @@ # frozen_string_literal: true require 'benchmark_helper' -require 'support/serializers/album_serializer' -require 'support/serializers/legacy_serializers' -require 'support/models/album' -RSpec.describe 'Memory Usage' do - let!(:album) { Album.abraxas.tap(&:attributes) } - let!(:albums) do +RSpec.describe 'Memory Usage', :benchmark do + let(:album) { Album.abraxas.tap(&:attributes) } + let(:albums) do 1000.times.map { Album.abraxas.tap(&:attributes) } end before do - AlbumSerializer.send(:instance) + AlbumSerializer.one_as_hash(album) + LegacyAlbumSerializer.new(album).to_json + AlbumBlueprint.render(album) + AlbumAlba.new(album).serialize + AlbumPanko.new.serialize_to_json(album) end - it 'should require less memory when serializing an object' do - oj_report = MemoryProfiler.report { AlbumSerializer.one(album).to_json } - bytes_allocated_by_oj = oj_report.allocated_memory_by_class.sum { |data| data[:count] } - - ams_report = MemoryProfiler.report { LegacyAlbumSerializer.new(album).to_json } - bytes_allocated_by_ams = ams_report.allocated_memory_by_class.sum { |data| data[:count] } + def allocated_by(entry) + entry.measurement.memory.allocated.to_f + end - expect(bytes_allocated_by_oj).to be < bytes_allocated_by_ams - expect(bytes_allocated_by_oj / bytes_allocated_by_ams.to_f).to be < 0.365 + it 'should require less memory when serializing an object' do + album + report = Benchmark.memory do |x| + x.report('oj') { Oj.dump AlbumSerializer.one_as_json(album) } + x.report('oj_hash') { Oj.dump AlbumSerializer.one_as_hash(album) } + x.report('ams') { Oj.dump LegacyAlbumSerializer.new(album) } + x.report('alba') { AlbumAlba.new(album).serialize } + x.report('panko') { AlbumPanko.new.serialize_to_json(album) } + x.report('blueprinter') { AlbumBlueprint.render(album) } + x.compare! + end + entries = report.comparison.entries + oj1, oj2, *rest = entries.map(&:label) + expect([oj1, oj2]).to contain_exactly(*%w[oj_hash oj]) + expect(rest).to eq %w[panko blueprinter ams alba] + expect(allocated_by(entries.first) / allocated_by(entries.last)).to be < 0.365 end it 'should require less memory when serializing a collection' do - oj_report = MemoryProfiler.report { Oj.dump AlbumSerializer.many(albums) } - bytes_allocated_by_oj = oj_report.allocated_memory_by_class.sum { |data| data[:count] } - - ams_report = MemoryProfiler.report { Oj.dump(albums.map { |album| LegacyAlbumSerializer.new(album) }) } - bytes_allocated_by_ams = ams_report.allocated_memory_by_class.sum { |data| data[:count] } - - expect(bytes_allocated_by_oj).to be < bytes_allocated_by_ams - expect(bytes_allocated_by_oj / bytes_allocated_by_ams.to_f).to be < 0.33 + albums + report = Benchmark.memory do |x| + x.report('oj') { Oj.dump AlbumSerializer.many_as_json(albums) } + x.report('oj_hash') { Oj.dump AlbumSerializer.many_as_hash(albums) } + x.report('ams') { Oj.dump(albums.map { |album| LegacyAlbumSerializer.new(album) }) } + x.report('alba') { AlbumAlba.new(albums).serialize } + x.report('panko') { Panko::ArraySerializer.new(albums, each_serializer: AlbumPanko).to_json } + x.report('blueprinter') { AlbumBlueprint.render(albums) } + x.compare! + end + entries = report.comparison.entries + expect(entries.map(&:label)).to eq %w[oj oj_hash panko blueprinter ams alba] + expect(allocated_by(entries.first) / allocated_by(entries.last)).to be < 0.33 end end diff --git a/benchmarks/model_serializer_benchmark.rb b/benchmarks/model_serializer_benchmark.rb index a192a3e..fadb67f 100644 --- a/benchmarks/model_serializer_benchmark.rb +++ b/benchmarks/model_serializer_benchmark.rb @@ -2,14 +2,7 @@ require 'benchmark_helper' -require 'rails' -require 'active_support/json' -require 'oj_serializers/compat' -require 'support/serializers/active_model_serializer' -require 'support/serializers/model_serializer' -require 'support/models/album' - -RSpec.describe ModelSerializer, category: :benchmark do +RSpec.describe 'ModelSerializer', :benchmark do context 'albums' do let!(:albums) do 100.times.map { Album.abraxas } @@ -21,6 +14,18 @@ x.report('oj_serializers') do Oj.dump ModelSerializer.many(albums) end + x.report('oj_serializers hash') do + Oj.dump ModelSerializer.many_as_hash(albums) + end + x.report('panko') do + Panko::ArraySerializer.new(albums, each_serializer: ModelPanko).to_json + end + x.report('alba') do + ModelAlba.new(albums).serialize + end + x.report('blueprinter') do + ModelBlueprint.render(albums) + end x.report('active_model_serializers') do Oj.dump(albums.map { |album| ActiveModelSerializer.new(album) }) end diff --git a/benchmarks/option_serializer_benchmark.rb b/benchmarks/option_serializer_benchmark.rb index 9e8aa8c..1cf91bc 100644 --- a/benchmarks/option_serializer_benchmark.rb +++ b/benchmarks/option_serializer_benchmark.rb @@ -2,33 +2,43 @@ require 'benchmark_helper' -require 'rails' -require 'active_support/json' -require 'oj_serializers/compat' -require 'support/serializers/option_serializer' -require 'support/models/album' - -RSpec.describe OptionSerializer, category: :benchmark do +RSpec.describe 'OptionSerializer', :benchmark do context 'albums' do let!(:albums) do 100.times.map { Album.abraxas } end it 'serializing models' do + some = albums.take(1) + expect(Oj.dump(OptionSerializer::Oj.many(some))).to eq OptionSerializer::Blueprinter.render(some) + expect(Oj.dump(OptionSerializer::Oj.many(some))).to eq(Oj.dump(some.map { |album| OptionSerializer::AMS.new(album) })) + Benchmark.ips do |x| x.config(time: 5, warmup: 2) - x.report('string_writer') do - Oj.dump OptionSerializer.write_models(albums) - end x.report('oj_serializers') do Oj.dump OptionSerializer::Oj.many(albums) end - x.report('map') do + x.report('oj_serializers (hash)') do + Oj.dump OptionSerializer::Oj.many_as_hash(albums) + end + x.report('map_models') do Oj.dump OptionSerializer.map_models(albums) end + x.report('write_models') do + Oj.dump OptionSerializer.write_models(albums) + end + x.report('alba') do + OptionSerializer::Alba.new(albums).serialize + end + x.report('panko') do + Panko::ArraySerializer.new(albums, each_serializer: OptionSerializer::Panko).to_json + end x.report('active_model_serializers') do Oj.dump(albums.map { |album| OptionSerializer::AMS.new(album) }) end + x.report('blueprinter') do + OptionSerializer::Blueprinter.render(albums) + end x.compare! end end diff --git a/benchmarks/record_benchmark.rb b/benchmarks/record_benchmark.rb index 7e24e73..3b50a63 100644 --- a/benchmarks/record_benchmark.rb +++ b/benchmarks/record_benchmark.rb @@ -3,7 +3,7 @@ require 'benchmark_helper' require 'support/models/sql' -RSpec.describe 'Record Accessors', category: :benchmark do +RSpec.describe 'Record Accessors', :benchmark do let(:player) { Game.example.players.first } it 'getters performance' do diff --git a/bin/bundle b/bin/bundle index fece50f..444cd8c 100755 --- a/bin/bundle +++ b/bin/bundle @@ -18,7 +18,7 @@ module_function end def env_var_version - ENV['BUNDLER_VERSION'] + ENV.fetch('BUNDLER_VERSION', nil) end def cli_arg_version @@ -38,7 +38,7 @@ module_function end def gemfile - gemfile = ENV['BUNDLE_GEMFILE'] + gemfile = ENV.fetch('BUNDLE_GEMFILE', nil) return gemfile if gemfile && !gemfile.empty? File.expand_path('../Gemfile', __dir__) diff --git a/bin/console b/bin/console index a8a27c0..dc7f16d 100755 --- a/bin/console +++ b/bin/console @@ -11,4 +11,16 @@ $LOAD_PATH.unshift Pathname.new(__dir__).join('../spec') Dir[Pathname.new(__dir__).join('../spec/support/**/*.rb')].sort.each { |f| require f } +def check + puts AlbumSerializer.send(:code_to_render_as_hash) +end + +def check_json + puts AlbumSerializer.send(:code_to_write_to_json) +end + +def axs + AlbumSerializer.one Album.abraxas +end + Pry.start diff --git a/examples/my_api/app/serializers/album_serializer.rb b/examples/my_api/app/serializers/album_serializer.rb index e113c56..3b7c4da 100644 --- a/examples/my_api/app/serializers/album_serializer.rb +++ b/examples/my_api/app/serializers/album_serializer.rb @@ -7,10 +7,10 @@ class AlbumSerializer < BaseSerializer :genres, ) - has_many :songs, serializer: SongSerializer - - attribute \ + attribute if: -> { album.released? } def release album.release_date.strftime('%B %d, %Y') - end, if: -> { album.released? } + end + + has_many :songs, serializer: SongSerializer end diff --git a/examples/my_api/app/serializers/song_serializer.rb b/examples/my_api/app/serializers/song_serializer.rb index ca03ea9..1b06369 100644 --- a/examples/my_api/app/serializers/song_serializer.rb +++ b/examples/my_api/app/serializers/song_serializer.rb @@ -7,7 +7,7 @@ class SongSerializer < BaseSerializer :name, ) - attribute \ + attribute def composers song.composer&.split(', ') end diff --git a/examples/my_api/bin/bundle b/examples/my_api/bin/bundle index 00bb33e..71bfb1a 100755 --- a/examples/my_api/bin/bundle +++ b/examples/my_api/bin/bundle @@ -18,7 +18,7 @@ module_function end def env_var_version - ENV['BUNDLER_VERSION'] + ENV.fetch('BUNDLER_VERSION', nil) end def cli_arg_version @@ -38,7 +38,7 @@ module_function end def gemfile - gemfile = ENV['BUNDLE_GEMFILE'] + gemfile = ENV.fetch('BUNDLE_GEMFILE', nil) return gemfile if gemfile && !gemfile.empty? File.expand_path('../Gemfile', __dir__) diff --git a/examples/my_api/config/environments/production.rb b/examples/my_api/config/environments/production.rb index 9d771cd..c8e12fe 100644 --- a/examples/my_api/config/environments/production.rb +++ b/examples/my_api/config/environments/production.rb @@ -69,14 +69,14 @@ config.active_support.deprecation = :notify # Use default logging formatter so that PID and timestamp are not suppressed. - config.log_formatter = ::Logger::Formatter.new + config.log_formatter = Logger::Formatter.new # Use a different logger for distributed setups. # require 'syslog/logger' # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') if ENV['RAILS_LOG_TO_STDOUT'].present? - logger = ActiveSupport::Logger.new(STDOUT) + logger = ActiveSupport::Logger.new($stdout) logger.formatter = config.log_formatter config.logger = ActiveSupport::TaggedLogging.new(logger) end diff --git a/examples/my_api/config/puma.rb b/examples/my_api/config/puma.rb index 6d84069..69e2645 100644 --- a/examples/my_api/config/puma.rb +++ b/examples/my_api/config/puma.rb @@ -6,20 +6,20 @@ # the maximum value specified for Puma. Default is set to 5 threads for minimum # and maximum; this matches the default thread size of Active Record. # -max_threads_count = ENV.fetch('RAILS_MAX_THREADS') { 5 } +max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count } threads min_threads_count, max_threads_count # Specifies the `port` that Puma will listen on to receive requests; default is 3000. # -port ENV.fetch('PORT') { 3000 } +port ENV.fetch('PORT', 3000) # Specifies the `environment` that Puma will run in. # -environment ENV.fetch('RAILS_ENV') { 'development' } +environment ENV.fetch('RAILS_ENV', 'development') # Specifies the `pidfile` that Puma will use. -pidfile ENV.fetch('PIDFILE') { 'tmp/pids/server.pid' } +pidfile ENV.fetch('PIDFILE', 'tmp/pids/server.pid') # Specifies the number of `workers` to boot in clustered mode. # Workers are forked web server processes. If using threads and workers together diff --git a/gemfiles/Gemfile-rails-edge b/gemfiles/Gemfile-rails-edge index 0400876..8e5c673 100644 --- a/gemfiles/Gemfile-rails-edge +++ b/gemfiles/Gemfile-rails-edge @@ -1,10 +1,8 @@ -source 'https://rubygems.org' +require_relative "base" -git_source(:github) { |repo| "https://github.com/#{repo}.git" } +eval main_gemfile gem 'rails', github: 'rails/rails', branch: 'main' gem 'actionpack', github: 'rails/rails', branch: 'main', glob: 'actionpack/*.gemspec' gem 'activerecord', github: 'rails/rails', branch: 'main', glob: 'activerecord/*.gemspec' gem 'railties', github: 'rails/rails', branch: 'main', glob: 'railties/*.gemspec' - -gemspec path: '..' diff --git a/gemfiles/Gemfile-rails.6.1.x b/gemfiles/Gemfile-rails.6.1.x index b06ec3c..507c778 100644 --- a/gemfiles/Gemfile-rails.6.1.x +++ b/gemfiles/Gemfile-rails.6.1.x @@ -1,5 +1,5 @@ -source 'https://rubygems.org' +require_relative "base" -gem 'rails', '~> 6.1.0' +eval main_gemfile -gemspec path: '..' +gem 'rails', '~> 6.1.0' diff --git a/gemfiles/Gemfile-rails.7.0.x b/gemfiles/Gemfile-rails.7.0.x index 4bb445c..4a3f154 100644 --- a/gemfiles/Gemfile-rails.7.0.x +++ b/gemfiles/Gemfile-rails.7.0.x @@ -1,5 +1,5 @@ -source 'https://rubygems.org' +require_relative "base" -gem 'rails', '~> 7.0.3' +eval main_gemfile -gemspec path: '..' +gem 'rails', '~> 7.0.3' diff --git a/gemfiles/base.rb b/gemfiles/base.rb new file mode 100644 index 0000000..076c259 --- /dev/null +++ b/gemfiles/base.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +def main_gemfile + "ENV['NO_RAILS'] = 'true'\n#{File.read('Gemfile').sub('gemspec', 'gemspec path: ".."')}" +end diff --git a/lib/oj_serializers/compat.rb b/lib/oj_serializers/compat.rb index 4704607..0807204 100644 --- a/lib/oj_serializers/compat.rb +++ b/lib/oj_serializers/compat.rb @@ -6,28 +6,13 @@ # as well. class ActiveModel::Serializer # JsonStringEncoder: Used internally to write a single object to JSON. - # - # Returns nothing. - def self.write_one(writer, object, options) - writer.push_value(new(object, options)) + def self.one(object, options = nil) + new(object, options) end # JsonStringEncoder: Used internally to write an array of objects to JSON. - # - # Returns nothing. - def self.write_many(writer, array, options) - writer.push_array - array.each do |object| - write_one(writer, object, options) - end - writer.pop - end - - # JsonStringEncoder: Used internally to instantiate an Oj::StringWriter. - # - # Returns an Oj::StringWriter. - def self.new_json_writer - OjSerializers::Serializer.send(:new_json_writer) + def self.many(array, options = nil) + array.map { |object| new(object, options) } end end diff --git a/lib/oj_serializers/json_string_encoder.rb b/lib/oj_serializers/json_string_encoder.rb index 7291754..28df55a 100644 --- a/lib/oj_serializers/json_string_encoder.rb +++ b/lib/oj_serializers/json_string_encoder.rb @@ -14,43 +14,27 @@ class << self # regardless of whether a serializer is specified or not. # # Returns a JSON string. - def encode_to_json(object, root: nil, serializer: nil, each_serializer: nil, **extras) - # NOTE: Serializers may override `new_json_writer` to modify the behavior. - writer = (serializer || each_serializer || OjSerializers::Serializer).send(:new_json_writer) - - if root - writer.push_object - writer.push_key(root.to_s) - end - - if serializer - serializer.write_one(writer, object, extras) + def encode_to_json(object, root: nil, serializer: nil, each_serializer: nil, **options) + result = if serializer + serializer.one(object, options) elsif each_serializer - each_serializer.write_many(writer, object, extras) + each_serializer.many(object, options) elsif object.is_a?(String) - return object unless root - - writer.push_json(object) + OjSerializers::JsonValue.new(object) else - writer.push_value(object) + object end - - writer.pop if root - - writer.to_json + Oj.dump(root ? { root => result } : result) end if OjSerializers::Serializer::DEV_MODE alias actual_encode_to_json encode_to_json # Internal: Allows to detect misusage of the options during development. - def encode_to_json(object, root: nil, serializer: nil, each_serializer: nil, **extras) - if serializer && serializer < OjSerializers::Serializer - raise ArgumentError, 'You must use `each_serializer` when serializing collections' if object.respond_to?(:each) - end - if each_serializer && each_serializer < OjSerializers::Serializer - raise ArgumentError, 'You must use `serializer` when serializing a single object' unless object.respond_to?(:each) - end - actual_encode_to_json(object, root: root, serializer: serializer, each_serializer: each_serializer, **extras) + def encode_to_json(object, root: nil, serializer: nil, each_serializer: nil, **options) + raise ArgumentError, 'You must use `each_serializer` when serializing collections' if serializer && serializer < OjSerializers::Serializer && object.respond_to?(:map) + raise ArgumentError, 'You must use `serializer` when serializing a single object' if each_serializer && each_serializer < OjSerializers::Serializer && !object.respond_to?(:map) + + actual_encode_to_json(object, root: root, serializer: serializer, each_serializer: each_serializer, **options) end end end diff --git a/lib/oj_serializers/serializer.rb b/lib/oj_serializers/serializer.rb index 0bac415..fdf6651 100644 --- a/lib/oj_serializers/serializer.rb +++ b/lib/oj_serializers/serializer.rb @@ -19,7 +19,7 @@ class OjSerializers::Serializer # Public: Used to validate incorrect memoization during development. Users of # this library might add additional options as needed. - ALLOWED_INSTANCE_VARIABLES = %w[memo object] + ALLOWED_INSTANCE_VARIABLES = %w[memo object options] CACHE = (defined?(Rails) && Rails.cache) || (defined?(ActiveSupport::Cache::MemoryStore) ? ActiveSupport::Cache::MemoryStore.new : OjSerializers::Memo.new) @@ -35,30 +35,17 @@ class OjSerializers::Serializer # Backwards Compatibility: Allows to access options passed through `render json`, # in the same way than ActiveModel::Serializers. def options - @object.try(:options) || DEFAULT_OPTIONS - end - - # Internal: Used internally to write attributes and associations to JSON. - # - # NOTE: Binds this instance to the specified object and options and writes - # to json using the provided writer. - def write_flat(writer, item) - @memo.clear if defined?(@memo) - @object = item - write_to_json(writer) + @options || DEFAULT_OPTIONS end # NOTE: Helps developers to remember to keep serializers stateless. if DEV_MODE - prepend(Module.new do - def write_flat(writer, item) - if instance_values.keys.any? { |key| !ALLOWED_INSTANCE_VARIABLES.include?(key) } - bad_keys = instance_values.keys.reject { |key| ALLOWED_INSTANCE_VARIABLES.include?(key) } - raise ArgumentError, "Serializer instances are reused so they must be stateless. Use `memo.fetch` for memoization purposes instead. Bad keys: #{bad_keys.join(',')}" - end - super + def _check_instance_variables + if instance_values.keys.any? { |key| !ALLOWED_INSTANCE_VARIABLES.include?(key) } + bad_keys = instance_values.keys.reject { |key| ALLOWED_INSTANCE_VARIABLES.include?(key) } + raise ArgumentError, "Serializer instances are reused so they must be stateless. Use `memo.fetch` for memoization purposes instead. Bad keys: #{bad_keys.join(',')} in #{self.class}" end - end) + end end # Internal: Used internally to write a single object to JSON. @@ -70,9 +57,8 @@ def write_flat(writer, item) # NOTE: Binds this instance to the specified object and options and writes # to json using the provided writer. def write_one(writer, item, options = nil) - item.define_singleton_method(:options) { options } if options writer.push_object - write_flat(writer, item) + write_to_json(writer, item, options) writer.pop end @@ -93,61 +79,43 @@ def write_many(writer, items, options = nil) # Internal: An internal cache that can be used for temporary memoization. def memo - defined?(@memo) ? @memo : @memo = OjSerializers::Memo.new - end - -private - - # Strategy: Writes an _id value to JSON using `id` as the key instead. - # NOTE: We skip the id for non-persisted documents, since it doesn't actually - # identify the document (it will change once it's persisted). - def write_value_using_id_strategy(writer, _key) - writer.push_value(@object.attributes['_id'], 'id') unless @object.new_record? + @memo ||= OjSerializers::Memo.new end - # Strategy: Writes an Mongoid attribute to JSON, this is the fastest strategy. - def write_value_using_mongoid_strategy(writer, key) - writer.push_value(@object.attributes[key], key) - end - - # Strategy: Writes a Hash value to JSON, works with String or Symbol keys. - def write_value_using_hash_strategy(writer, key) - writer.push_value(@object[key], key.to_s) - end - - # Strategy: Obtains the value by calling a method in the object, and writes it. - def write_value_using_method_strategy(writer, key) - writer.push_value(@object.send(key), key) - end + class << self + # Public: Allows the user to specify `default_format :json`, as a simple + # way to ensure that `.one` and `.many` work as in Version 1. + def default_format(value) + @_default_format = value + define_serialization_shortcuts + end - # Strategy: Obtains the value by calling a method in the serializer. - def write_value_using_serializer_strategy(writer, key) - writer.push_value(send(key), key) - end + # Public: Allows to sort fields by name instead. + def sort_attributes_by(value) + @_sort_attributes_by = case value + when :name then ->(name, options) { options[:identifier] ? "__#{name}" : name } + when Proc then value + else + raise ArgumentError, "Unknown sorting option: #{value.inspect}" + end + end - # Override to detect missing attribute errors locally. - if DEV_MODE - alias original_write_value_using_method_strategy write_value_using_method_strategy - def write_value_using_method_strategy(writer, key) - original_write_value_using_method_strategy(writer, key) - rescue NoMethodError => e - raise e, "Perhaps you meant to call #{key.inspect} in #{self.class.name} instead?\nTry using `serializer_attributes :#{key}` or `attribute def #{key}`.\n#{e.message}" - end - - alias original_write_value_using_mongoid_strategy write_value_using_mongoid_strategy - def write_value_using_mongoid_strategy(writer, key) - original_write_value_using_mongoid_strategy(writer, key).tap do - # Apply a fake selection when 'only' is not used, so that we allow - # read_attribute to fail on typos, renamed, and removed fields. - @object.__selected_fields = @object.fields.merge(@object.relations.select { |_key, value| value.embedded? }).transform_values { 1 } unless @object.__selected_fields - @object.read_attribute(key) # Raise a missing attribute exception if it's missing. + # Public: Allows to sort fields by name instead. + def transform_keys(transformer = nil, &block) + @_transform_keys = case (transformer ||= block) + when :camelize, :camel_case then ->(key) { key.to_s.camelize(:lower) } + when Symbol then transformer.to_proc + when Proc then transformer + else + raise(ArgumentError, "Expected transform_keys to be callable, got: #{transformer.inspect}") end - rescue StandardError => e - raise ActiveModel::MissingAttributeError, "#{e.message} in #{self.class} for #{@object.inspect}" end - end - class << self + # Public: Creates an alias for the internal object. + def object_as(name, **) + define_method(name) { @object } + end + # Internal: We want to discourage instantiating serializers directly, as it # prevents the possibility of reusing an instance. # @@ -156,15 +124,25 @@ class << self # Internal: Delegates to the instance methods, the advantage is that we can # reuse the same serializer instance to serialize different objects. - delegate :write_one, :write_many, :write_flat, to: :instance + delegate :write_one, :write_many, :write_to_json, to: :instance # Internal: Keep a reference to the default `write_one` method so that we # can use it inside cached overrides and benchmark tests. - alias non_cached_write_one write_one + alias_method :non_cached_write_one, :write_one # Internal: Keep a reference to the default `write_many` method so that we # can use it inside cached overrides and benchmark tests. - alias non_cached_write_many write_many + alias_method :non_cached_write_many, :write_many + + # Helper: Serializes one or more items. + def render(item, options = nil) + many?(item) ? many(item, options) : one(item, options) + end + + # Helper: Serializes one or more items. + def render_as_hash(item, options = nil) + many?(item) ? many_as_hash(item, options) : one_as_hash(item, options) + end # Helper: Serializes the item unless it's nil. def one_if(item, options = nil) @@ -177,7 +155,7 @@ def one_if(item, options = nil) # options - list of external options to pass to the sub class (available in `item.options`) # # Returns an Oj::StringWriter instance, which is encoded as raw json. - def one(item, options = nil) + def one_as_json(item, options = nil) writer = new_json_writer write_one(writer, item, options) writer @@ -189,21 +167,39 @@ def one(item, options = nil) # options - list of external options to pass to the sub class (available in `item.options`) # # Returns an Oj::StringWriter instance, which is encoded as raw json. - def many(items, options = nil) + def many_as_json(items, options = nil) writer = new_json_writer write_many(writer, items, options) writer end - # Public: Creates an alias for the internal object. - def object_as(name) - define_method(name) { @object } + # Public: Renders the configured attributes for the specified object, + # without serializing to JSON. + # + # item - the item to serialize + # options - list of external options to pass to the sub class (available in `item.options`) + # + # Returns a Hash, with the attributes specified in the serializer. + def one_as_hash(item, options = nil) + instance.render_as_hash(item, options) + end + + # Public: Renders an array of items using this serializer, without + # serializing to JSON. + # + # items - Must respond to `each`. + # options - list of external options to pass to the sub class (available in `item.options`) + # + # Returns an Array of Hash, each with the attributes specified in the serializer. + def many_as_hash(items, options = nil) + serializer = instance + items.map { |item| serializer.render_as_hash(item, options) } end # Internal: Will alias the object according to the name of the wrapper class. def inherited(subclass) object_alias = subclass.name.demodulize.chomp('Serializer').underscore - subclass.object_as(object_alias) unless method_defined?(object_alias) + subclass.object_as(object_alias) unless method_defined?(object_alias) || object_alias == 'base' super end @@ -211,15 +207,7 @@ def inherited(subclass) # # Any attributes defined in parent classes are inherited. def _attributes - @_attributes = superclass.try(:_attributes)&.dup || {} unless defined?(@_attributes) - @_attributes - end - - # Internal: List of associations to be serialized. - # Any associations defined in parent classes are inherited. - def _associations - @_associations = superclass.try(:_associations)&.dup || {} unless defined?(@_associations) - @_associations + @_attributes ||= superclass.try(:_attributes)&.dup || {} end protected @@ -235,6 +223,36 @@ def item_cache_key(item, cache_key_proc) # NOTE: Benchmark it, sometimes caching is actually SLOWER. def cached(cache_key_proc = :cache_key.to_proc) cache_options = { namespace: "#{name}#write_to_json", version: OjSerializers::VERSION }.freeze + cache_hash_options = { namespace: "#{name}#render_as_hash", version: OjSerializers::VERSION }.freeze + + # Internal: Redefine `one_as_hash` to use the cache for the serialized hash. + define_singleton_method(:one_as_hash) do |item, options = nil| + CACHE.fetch(item_cache_key(item, cache_key_proc), cache_hash_options) do + instance.render_as_hash(item, options) + end + end + + # Internal: Redefine `many_as_hash` to use the cache for the serialized hash. + define_singleton_method(:many_as_hash) do |items, options = nil| + # We define a one-off method for the class to receive the entire object + # inside the `fetch_multi` block. Otherwise we would only get the cache + # key, and we would need to build a Hash to retrieve the object. + # + # NOTE: The assignment is important, as queries would return different + # objects when expanding with the splat in fetch_multi. + items = items.entries.each do |item| + item_key = item_cache_key(item, cache_key_proc) + item.define_singleton_method(:cache_key) { item_key } + end + + # Fetch all items at once by leveraging `read_multi`. + # + # NOTE: Memcached does not support `write_multi`, if we switch the cache + # store to use Redis performance would improve a lot for this case. + CACHE.fetch_multi(*items, cache_hash_options) do |item| + instance.render_as_hash(item, options) + end.values + end # Internal: Redefine `write_one` to use the cache for the serialized JSON. define_singleton_method(:write_one) do |external_writer, item, options = nil| @@ -270,36 +288,60 @@ def cached(cache_key_proc = :cache_key.to_proc) end.values external_writer.push_json("#{OjSerializers::JsonValue.array(cached_items)}\n") # Oj.dump expects a new line terminator. end + + define_serialization_shortcuts + end + alias_method :cached_with_key, :cached + + def define_serialization_shortcuts(format = _default_format) + case format + when :json, :hash + singleton_class.alias_method :one, :"one_as_#{format}" + singleton_class.alias_method :many, :"many_as_#{format}" + else + raise ArgumentError, "Unknown serialization format: #{format.inspect}" + end end - alias cached_with_key cached # Internal: The writer to use to write to json def new_json_writer Oj::StringWriter.new(mode: :rails) end + # Public: Identifiers are always serialized first. + # + # NOTE: We skip the id for non-persisted documents, since it doesn't + # actually identify the document (it will change once it's persisted). + def identifier(name = :id, **options) + add_attribute(name, attribute: :method, if: -> { !@object.new_record? }, **options, identifier: true) + end + # Public: Specify a collection of objects that should be serialized using # the specified serializer. - def has_many(name, root: name, serializer:, **options) - add_association(name, write_method: :write_many, root: root, serializer: serializer, **options) + def has_many(name, serializer:, root: name, as: root, **options, &block) + define_method(name, &block) if block + add_attribute(name, association: :many, as: as, serializer: serializer, **options) end # Public: Specify an object that should be serialized using the serializer. - def has_one(name, root: name, serializer:, **options) - add_association(name, write_method: :write_one, root: root, serializer: serializer, **options) + def has_one(name, serializer:, root: name, as: root, **options, &block) + define_method(name, &block) if block + add_attribute(name, association: :one, as: as, serializer: serializer, **options) end + # Alias: From a serializer perspective, the association type does not matter. + alias_method :belongs_to, :has_one # Public: Specify an object that should be serialized using the serializer, # but unlike `has_one`, this one will write the attributes directly without # wrapping it in an object. - def flat_one(name, root: false, serializer:, **options) - add_association(name, write_method: :write_flat, root: root, serializer: serializer, **options) + def flat_one(name, serializer:, **options) + add_attribute(name, association: :flat, serializer: serializer, **options) end # Public: Specify which attributes are going to be obtained from indexing # the object. def hash_attributes(*method_names, **options) - options = { **options, strategy: :write_value_using_hash_strategy } + options = { **options, attribute: :hash } method_names.each { |name| _attributes[name] = options } end @@ -310,32 +352,59 @@ def hash_attributes(*method_names, **options) # # See ./benchmarks/document_benchmark.rb def mongo_attributes(*method_names, **options) - add_attribute('id', **options, strategy: :write_value_using_id_strategy) if method_names.delete(:id) - add_attributes(method_names, **options, strategy: :write_value_using_mongoid_strategy) + identifier(:_id, as: :id, attribute: :mongoid, **options) if method_names.delete(:id) + attributes(*method_names, **options, attribute: :mongoid) end # Public: Specify which attributes are going to be obtained by calling a # method in the object. - def attributes(*method_names, **options) - add_attributes(method_names, **options, strategy: :write_value_using_method_strategy) + def attributes(*method_names, **methods_with_options) + attr_options = methods_with_options.extract!(:if, :as, :attribute) + attr_options[:attribute] ||= :method + + method_names.each do |name| + add_attribute(name, attr_options) + end + + methods_with_options.each do |name, options| + options = { as: options } if options.is_a?(Symbol) + add_attribute(name, options) + end end # Public: Specify which attributes are going to be obtained by calling a # method in the serializer. - # - # NOTE: This can be one of the slowest strategies, when in doubt, measure. def serializer_attributes(*method_names, **options) - add_attributes(method_names, **options, strategy: :write_value_using_serializer_strategy) + attributes(*method_names, **options, attribute: :serializer) end # Syntax Sugar: Allows to use it before a method name. # # Example: - # attribute \ + # attribute # def full_name # "#{ first_name } #{ last_name }" # end - alias attribute serializer_attributes + def attribute(name = nil, **options, &block) + options[:attribute] = :serializer + if name + define_method(name, &block) if block + add_attribute(name, options) + else + @_current_attribute_options = options + end + end + alias_method :attr, :attribute + + # Internal: Intercept a method definition, tying a type that was + # previously specified to the name of the attribute. + def method_added(name) + super(name) + if @_current_attribute_options + add_attribute(name, @_current_attribute_options) + @_current_attribute_options = nil + end + end # Backwards Compatibility: Meant only to replace Active Model Serializers, # calling a method in the serializer, or using `read_attribute_for_serialization`. @@ -345,21 +414,48 @@ def ams_attributes(*method_names, **options) method_names.each do |method_name| define_method(method_name) { @object.read_attribute_for_serialization(method_name) } unless method_defined?(method_name) end - add_attributes(method_names, **options, strategy: :write_value_using_serializer_strategy) + attributes(*method_names, **options, attribute: :serializer) end - private + # Internal: The default format to use for `render`, `one`, and `many`. + def _default_format + @_default_format = superclass.try(:_default_format) || :hash unless defined?(@_default_format) + @_default_format + end - def add_attributes(names, options) - names.each { |name| add_attribute(name, options) } + # Internal: The strategy to use when sorting the fields. + # + # This setting is inherited from parent classes. + def _sort_attributes_by + @_sort_attributes_by = superclass.try(:_sort_attributes_by) unless defined?(@_sort_attributes_by) + @_sort_attributes_by end + # Internal: The converter to use for serializer keys. + # + # This setting is inherited from parent classes. + def _transform_keys + @_transform_keys = superclass.try(:_transform_keys) unless defined?(@_transform_keys) + @_transform_keys + end + + private + def add_attribute(name, options) _attributes[name.to_s.freeze] = options end - def add_association(name, options) - _associations[name.to_s.freeze] = options + # Internal: Transforms the keys using the provided strategy. + def key_for(method_name, options) + key = options.fetch(:as, method_name) + _transform_keys ? _transform_keys.call(key) : key + end + + # Internal: Whether the object should be serialized as a collection. + def many?(item) + item.is_a?(Array) || + (defined?(ActiveRecord::Relation) && item.is_a?(ActiveRecord::Relation)) || + (defined?(Mongoid::Association::Many) && item.is_a?(Mongoid::Association::Many)) end # Internal: We generate code for the serializer to avoid the overhead of @@ -368,38 +464,92 @@ def add_association(name, options) # # As a result, the performance is the same as writing the most efficient # code by hand. - def write_to_json_body + def code_to_write_to_json <<~WRITE_TO_JSON # Public: Writes this serializer content to a provided Oj::StringWriter. - def write_to_json(writer) - #{ _attributes.map { |method_name, attribute_options| - write_conditional_body(method_name, attribute_options) { - <<-WRITE_ATTRIBUTE - #{attribute_options.fetch(:strategy)}(writer, #{method_name.inspect}) - WRITE_ATTRIBUTE + def write_to_json(writer, item, options = nil) + @object = item + @options = options + @memo.clear if defined?(@memo) + #{ _attributes.map { |method_name, options| + code_to_write_conditional(method_name, options) { + if options[:association] + code_to_write_association(method_name, options) + else + code_to_write_attribute(method_name, options) + end } - }.join } - #{ _associations.map { |method_name, association_options| - write_conditional_body(method_name, association_options) { - write_association_body(method_name, association_options) - } - }.join} + }.join("\n ") }#{code_to_rescue_no_method if DEV_MODE} end WRITE_TO_JSON end - # Internal: Returns the code to render an attribute or association - # conditionally. + # Internal: We generate code for the serializer to avoid the overhead of + # using variables for method names, having to iterate the list of attributes + # and associations, and the overhead of using `send` with dynamic methods. # - # NOTE: Detects any include methods defined in the serializer, or defines + # As a result, the performance is the same as writing the most efficient + # code by hand. + def code_to_render_as_hash + <<~RENDER_AS_HASH + # Public: Writes this serializer content to a Hash. + def render_as_hash(item, options = nil) + @object = item + @options = options + @memo.clear if defined?(@memo) + { + #{_attributes.map { |method_name, options| + code_to_render_conditionally(method_name, options) { + if options[:association] + code_to_render_association(method_name, options) + else + code_to_render_attribute(method_name, options) + end + } + }.join(",\n ")} + }#{code_to_rescue_no_method if DEV_MODE} + end + RENDER_AS_HASH + end + + def code_to_rescue_no_method + <<~RESCUE_NO_METHOD + + rescue NoMethodError => e + key = e.name.to_s.inspect + message = if respond_to?(e.name) + raise e, "Perhaps you meant to call \#{key} in \#{self.class} instead?\nTry using `serializer_attributes :\#{key}` or `attribute def \#{key}`.\n\#{e.message}" + elsif @object.respond_to?(e.name) + raise e, "Perhaps you meant to call \#{key} in \#{@object.class} instead?\nTry using `attributes :\#{key}`.\n\#{e.message}" + else + raise e + end + ensure + _check_instance_variables + RESCUE_NO_METHOD + end + + # Internal: Detects any include methods defined in the serializer, or defines # one by using the lambda passed in the `if` option, if any. - def write_conditional_body(method_name, options) - include_method_name = "include_#{method_name}?" + def check_conditional_method(method_name, options) + include_method_name = "include_#{method_name}#{'?' unless method_name.to_s.ends_with?('?')}" if render_if = options[:if] - define_method(include_method_name, &render_if) + if render_if.is_a?(Symbol) + alias_method(include_method_name, render_if) + else + define_method(include_method_name, &render_if) + end end + include_method_name if method_defined?(include_method_name) + end - if method_defined?(include_method_name) + # Internal: Returns the code to render an attribute or association + # conditionally. + # + # NOTE: Detects any include methods defined in the serializer, or defines + # one by using the lambda passed in the `if` option, if any. + def code_to_write_conditional(method_name, options) + if (include_method_name = check_conditional_method(method_name, options)) "if #{include_method_name};#{yield};end\n" else yield @@ -407,31 +557,102 @@ def write_conditional_body(method_name, options) end # Internal: Returns the code for the association method. - def write_association_body(method_name, association_options) + def code_to_write_attribute(method_name, options) + key = key_for(method_name, options).to_s.inspect + + case strategy = options.fetch(:attribute) + when :serializer + # Obtains the value by calling a method in the serializer. + "writer.push_value(#{method_name}, #{key})" + when :method + # Obtains the value by calling a method in the object, and writes it. + "writer.push_value(@object.#{method_name}, #{key})" + when :hash + # Writes a Hash value to JSON, works with String or Symbol keys. + "writer.push_value(@object[#{method_name.inspect}], #{key})" + when :mongoid + # Writes an Mongoid attribute to JSON, this is the fastest strategy. + "writer.push_value(@object.attributes['#{method_name}'], #{key})" + else + raise ArgumentError, "Unknown attribute strategy: #{strategy.inspect}" + end + end + + # Internal: Returns the code for the association method. + def code_to_write_association(method_name, options) # Use a serializer method if defined, else call the association in the object. association_method = method_defined?(method_name) ? method_name : "@object.#{method_name}" - association_root = association_options[:root] - serializer_class = association_options.fetch(:serializer) - - case write_method = association_options.fetch(:write_method) - when :write_one - <<-WRITE_ONE - if associated_object = #{association_method} - writer.push_key(#{association_root.to_s.inspect}) - #{serializer_class}.write_one(writer, associated_object) - end + key = key_for(method_name, options) + serializer_class = options.fetch(:serializer) + + case type = options.fetch(:association) + when :one + <<~WRITE_ONE + if associated_object = #{association_method} + writer.push_key('#{key}') + #{serializer_class}.write_one(writer, associated_object) + end WRITE_ONE - when :write_many - <<-WRITE_MANY - writer.push_key(#{association_root.to_s.inspect}) - #{serializer_class}.write_many(writer, #{association_method}) + when :many + <<~WRITE_MANY + writer.push_key('#{key}') + #{serializer_class}.write_many(writer, #{association_method}) WRITE_MANY - when :write_flat - <<-WRITE_FLAT - #{serializer_class}.write_flat(writer, #{association_method}) + when :flat + <<~WRITE_FLAT + #{serializer_class}.write_to_json(writer, #{association_method}) WRITE_FLAT else - raise ArgumentError, "Unknown write_method #{write_method}" + raise ArgumentError, "Unknown association type: #{type.inspect}" + end + end + + # Internal: Returns the code to render an attribute or association + # conditionally. + # + # NOTE: Detects any include methods defined in the serializer, or defines + # one by using the lambda passed in the `if` option, if any. + def code_to_render_conditionally(method_name, options) + if (include_method_name = check_conditional_method(method_name, options)) + "**(#{include_method_name} ? {#{yield}} : {})" + else + yield + end + end + + # Internal: Returns the code for the attribute method. + def code_to_render_attribute(method_name, options) + key = key_for(method_name, options) + case strategy = options.fetch(:attribute) + when :serializer + "#{key}: #{method_name}" + when :method + "#{key}: @object.#{method_name}" + when :hash + "#{key}: @object[#{method_name.inspect}]" + when :mongoid + "#{key}: @object.attributes['#{method_name}']" + else + raise ArgumentError, "Unknown attribute strategy: #{strategy.inspect}" + end + end + + # Internal: Returns the code for the association method. + def code_to_render_association(method_name, options) + # Use a serializer method if defined, else call the association in the object. + association = method_defined?(method_name) ? method_name : "@object.#{method_name}" + key = key_for(method_name, options) + serializer_class = options.fetch(:serializer) + + case type = options.fetch(:association) + when :one + "#{key}: (one_item = #{association}) ? #{serializer_class}.one_as_hash(one_item) : nil" + when :many + "#{key}: #{serializer_class}.many_as_hash(#{association})" + when :flat + "**#{serializer_class}.one_as_hash(#{association})" + else + raise ArgumentError, "Unknown association type: #{type.inspect}" end end @@ -447,16 +668,28 @@ def instance # Internal: Cache key to set a thread-local instance. def instance_key - unless defined?(@instance_key) - @instance_key = "#{name.underscore}_instance_#{object_id}".to_sym + @instance_key ||= begin # We take advantage of the fact that this method will always be called - # before instantiating a serializer to define the write_to_json method. - class_eval(write_to_json_body) - raise ArgumentError, "You must use `cached ->(object) { ... }` in order to specify a different cache key when subclassing #{name}." if method_defined?(:cache_key) || respond_to?(:cache_key) + # before instantiating a serializer, to apply last minute adjustments. + _prepare_serializer + "#{name.underscore}_instance_#{object_id}".to_sym end - @instance_key + end + + # Internal: Generates write_to_json and render_as_hash methods optimized for + # the specified configuration. + def _prepare_serializer + if _sort_attributes_by + @_attributes = _attributes.sort_by { |key, options| + _sort_attributes_by.call(key, options) + }.to_h + end + class_eval(code_to_write_to_json) + class_eval(code_to_render_as_hash) end end + + define_serialization_shortcuts end Oj::Serializer = OjSerializers::Serializer unless defined?(Oj::Serializer) diff --git a/lib/oj_serializers/version.rb b/lib/oj_serializers/version.rb index 32bb9fe..ac877b0 100644 --- a/lib/oj_serializers/version.rb +++ b/lib/oj_serializers/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module OjSerializers - VERSION = '1.0.2' + VERSION = '2.0.0' end diff --git a/oj_serializers.gemspec b/oj_serializers.gemspec index 0588491..2c6fa62 100644 --- a/oj_serializers.gemspec +++ b/oj_serializers.gemspec @@ -12,7 +12,7 @@ Gem::Specification.new do |spec| spec.description = 'oj_serializers leverages the performance of the oj JSON serialization library, and minimizes object allocations, all while provding a similar API to Active Model Serializers.' spec.homepage = 'https://github.com/ElMassimo/oj_serializers' spec.license = 'MIT' - spec.required_ruby_version = Gem::Requirement.new('>= 2.3.0') + spec.required_ruby_version = Gem::Requirement.new('>= 2.7.0') spec.metadata['homepage_uri'] = spec.homepage spec.metadata['source_code_uri'] = 'https://github.com/ElMassimo/oj_serializers' @@ -22,20 +22,7 @@ Gem::Specification.new do |spec| spec.files = Dir.glob('{lib}/**/*.rb') + %w[README.md CHANGELOG.md] spec.require_paths = ['lib'] - rails_versions = '>= 4.0' - spec.add_dependency 'oj', '>= 3.14.0' - spec.add_development_dependency 'actionpack', rails_versions - spec.add_development_dependency 'active_model_serializers', '~> 0.8' - spec.add_development_dependency 'activerecord' - spec.add_development_dependency 'benchmark-ips' - spec.add_development_dependency 'memory_profiler' - spec.add_development_dependency 'mongoid' - spec.add_development_dependency 'pry-byebug', '~> 3.9' - spec.add_development_dependency 'railties', rails_versions - spec.add_development_dependency 'rake', '~> 13.0' - spec.add_development_dependency 'rspec-rails', '~> 4.0' - spec.add_development_dependency 'simplecov', '< 0.18' - spec.add_development_dependency 'sqlite3' + spec.metadata['rubygems_mfa_required'] = 'true' end diff --git a/spec/benchmark_helper.rb b/spec/benchmark_helper.rb index d9a3705..10de2d0 100644 --- a/spec/benchmark_helper.rb +++ b/spec/benchmark_helper.rb @@ -1,8 +1,18 @@ # frozen_string_literal: true -ENV['BENCHMARK'] ||= 'true' +ENV['BENCHMARK'] = 'true' require 'spec_helper' require 'benchmark/ips' +require 'benchmark/memory' require 'benchmark' require 'memory_profiler' + +require 'rails' +require 'active_support/json' +require 'oj_serializers/compat' + +Dir[Pathname.new(__dir__).join('support/**/*.rb')].sort.each { |f| require f } + +require 'singed' +Singed.output_directory = 'benchmarks/flamegraphs' diff --git a/spec/oj_serializers/caching_spec.rb b/spec/oj_serializers/caching_spec.rb index 33712c4..08ce73a 100644 --- a/spec/oj_serializers/caching_spec.rb +++ b/spec/oj_serializers/caching_spec.rb @@ -16,15 +16,26 @@ class CachedAlbumSerializer < AlbumSerializer RSpec.describe 'Caching', type: :serializer do let!(:album) { Album.abraxas } - let!(:other_album) { Album.new(name: 'Amigos', release_date: Date.new(1976, 3, 26)) } + let!(:other_album) { Album.new(id: 1, name: 'Amigos', release_date: Date.new(1976, 3, 26)) } let!(:albums) { [album, other_album] } + before do + # NOTE: Uncomment to debug test failures. + # Oj::Serializer::CACHE.logger = ActiveSupport::Logger.new(STDOUT) + end + it 'should reuse the cache effectively' do + allow_any_instance_of(Mongoid::Document).to receive(:new_record?).and_return(false) + attrs = parse_json(AlbumSerializer.one(album)) expect(attrs).to include(name: album.name) other_attrs = parse_json(AlbumSerializer.one(other_album)) - expect(other_attrs).to eq(name: 'Amigos', release: 'March 26, 1976', genres: nil, songs: []) + expect(other_attrs).to eq(id: 1, name: 'Amigos', release: 'March 26, 1976', genres: nil, songs: []) + expect(album.songs.first).to receive(:composer).once.and_call_original + expect_parsed_json(CachedSongSerializer.many(album.songs)).to eq attrs[:songs] + + expect(album.songs.first).not_to receive(:composer) expect(album).to receive(:release_date).once.and_call_original expect(other_album).to receive(:release_date).once.and_call_original expect_parsed_json(CachedAlbumSerializer.one(album)).to eq attrs @@ -35,4 +46,21 @@ class CachedAlbumSerializer < AlbumSerializer expect_parsed_json(CachedAlbumSerializer.one(other_album)).to eq other_attrs expect_parsed_json(CachedAlbumSerializer.many(albums)).to eq [attrs, other_attrs] end + + it 'should reuse the cache effectively for JSON' do + attrs = parse_json(AlbumSerializer.one_as_json(album)) + expect(attrs).to include(name: album.name) + other_attrs = parse_json(AlbumSerializer.one_as_json(other_album)) + expect(other_attrs).to eq(name: 'Amigos', release: 'March 26, 1976', genres: nil, songs: []) + + expect(album).to receive(:release_date).once.and_call_original + expect(other_album).to receive(:release_date).once.and_call_original + expect_parsed_json(CachedAlbumSerializer.one_as_json(album)).to eq attrs + expect_parsed_json(CachedAlbumSerializer.one_as_json(other_album)).to eq other_attrs + + expect_any_instance_of(Album).not_to receive(:release_date) + expect_parsed_json(CachedAlbumSerializer.one_as_json(album)).to eq attrs + expect_parsed_json(CachedAlbumSerializer.one_as_json(other_album)).to eq other_attrs + expect_parsed_json(CachedAlbumSerializer.many_as_json(albums)).to eq [attrs, other_attrs] + end end diff --git a/spec/oj_serializers/dev_mode_spec.rb b/spec/oj_serializers/dev_mode_spec.rb index 012342d..686b7f2 100644 --- a/spec/oj_serializers/dev_mode_spec.rb +++ b/spec/oj_serializers/dev_mode_spec.rb @@ -7,7 +7,7 @@ class StatefulSerializer < Oj::Serializer hash_attributes 'genre' - attribute \ + attribute def name @name ||= @object.name end @@ -26,7 +26,7 @@ class MissingAttributeSerializer < Oj::Serializer it 'should fail early when memoization is used incorrectly' do expect { StatefulSerializer.many([album, album]) } - .to raise_error(ArgumentError, 'Serializer instances are reused so they must be stateless. Use `memo.fetch` for memoization purposes instead. Bad keys: name') + .to raise_error(ArgumentError, 'Serializer instances are reused so they must be stateless. Use `memo.fetch` for memoization purposes instead. Bad keys: name in StatefulSerializer') end it 'should fail early when `attributes` is used instead of `serializer_attributes`' do @@ -34,7 +34,7 @@ class MissingAttributeSerializer < Oj::Serializer .to raise_error(NoMethodError, /Perhaps you meant to call "release" in InvalidAlbumSerializer instead?/) end - it 'should fail early when there is a typo or missing field in Mongoid' do + xit 'should fail early when there is a typo or missing field in Mongoid' do expect { MissingAttributeSerializer.one_if(album) } .to raise_error(ActiveModel::MissingAttributeError, /Missing attribute: 'name2'/) end diff --git a/spec/oj_serializers/json_string_encoder_spec.rb b/spec/oj_serializers/json_string_encoder_spec.rb index df54133..ba972ae 100644 --- a/spec/oj_serializers/json_string_encoder_spec.rb +++ b/spec/oj_serializers/json_string_encoder_spec.rb @@ -63,77 +63,77 @@ def expect_incorrect_usage(object, options = {}) let(:album) { Album.abraxas } let(:album_hash) do { - "name": 'Abraxas', - "genres": [ + name: 'Abraxas', + genres: [ 'Pyschodelic Rock', 'Blues Rock', 'Jazz Fusion', 'Latin Rock', ], - "release": 'September 23, 1970', - "songs": [ + release: 'September 23, 1970', + songs: [ { - "track": 1, - "name": 'Sing Winds, Crying Beasts', - "composers": [ + track: 1, + name: 'Sing Winds, Crying Beasts', + composers: [ 'Michael Carabello', ], }, { - "track": 2, - "name": 'Black Magic Woman / Gypsy Queen', - "composers": [ + track: 2, + name: 'Black Magic Woman / Gypsy Queen', + composers: [ 'Peter Green', 'Gábor Szabó', ], }, { - "track": 3, - "name": 'Oye como va', - "composers": [ + track: 3, + name: 'Oye como va', + composers: [ 'Tito Puente', ], }, { - "track": 4, - "name": 'Incident at Neshabur', - "composers": [ + track: 4, + name: 'Incident at Neshabur', + composers: [ 'Alberto Gianquinto', 'Carlos Santana', ], }, { - "track": 5, - "name": 'Se acabó', - "composers": [ + track: 5, + name: 'Se acabó', + composers: [ 'José Areas', ], }, { - "track": 6, - "name": "Mother's Daughter", - "composers": [ + track: 6, + name: "Mother's Daughter", + composers: [ 'Gregg Rolie', ], }, { - "track": 7, - "name": 'Samba pa ti', - "composers": [ + track: 7, + name: 'Samba pa ti', + composers: [ 'Santana', ], }, { - "track": 8, - "name": "Hope You're Feeling Better", - "composers": [ + track: 8, + name: "Hope You're Feeling Better", + composers: [ 'Rolie', ], }, { - "track": 9, - "name": 'El Nicoya', - "composers": [ + track: 9, + name: 'El Nicoya', + composers: [ 'Areas', ], }, diff --git a/spec/oj_serializers/sugar_spec.rb b/spec/oj_serializers/sugar_spec.rb index 5e438cb..372a0c5 100644 --- a/spec/oj_serializers/sugar_spec.rb +++ b/spec/oj_serializers/sugar_spec.rb @@ -11,7 +11,7 @@ Rails.application.quick_setup end - it 'should be able to use serializer options and legacy serializers' do + it 'should use serializer options' do get :list albums = parse_json[:albums] expect(albums.size).to eq 3 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 9a6cccc..eb414d9 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -20,6 +20,8 @@ def as_json(_options = nil) module JsonHelpers def parse_json(json = response.body) + return json if json.is_a?(Array) || json.is_a?(Hash) + item = JSON.parse(json) item.is_a?(Array) ? item.map(&:deep_symbolize_keys) : item.deep_symbolize_keys! end @@ -43,4 +45,8 @@ def expect_parsed_json(json = response.body) end config.include JsonHelpers + + config.before(benchmark: true) do + raise ArgumentError, "Please run it with BENCHMARK='true'" unless ENV['BENCHMARK'] + end end diff --git a/spec/support/models/sql.rb b/spec/support/models/sql.rb index e90341b..04ec673 100644 --- a/spec/support/models/sql.rb +++ b/spec/support/models/sql.rb @@ -57,7 +57,7 @@ def full_name end class PlayerSerializer < Oj::Serializer - attributes :id, if: -> { player.persisted? } + identifier attributes :first_name, :last_name, :full_name end @@ -68,7 +68,7 @@ class ScoresSerializer < Oj::Serializer end class GameSerializer < Oj::Serializer - attributes :id, if: -> { game.persisted? } + identifier attributes :name flat_one :game, serializer: ScoresSerializer diff --git a/spec/support/serializers/alba.rb b/spec/support/serializers/alba.rb new file mode 100644 index 0000000..5a5330b --- /dev/null +++ b/spec/support/serializers/alba.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'alba' + +Alba.backend = :oj_rails +Alba.inflector = :active_support + +class BaseAlba + include Alba::Resource + transform_keys :lower_camel +end + +class SongAlba < BaseAlba + attributes :id, if: proc { |song| !song.new_record? } + + attributes( + :track, + :name, + :composers, + ) + + def composers(song) + song.composer&.split(', ') + end +end + +class AlbumAlba < BaseAlba + attributes :id, if: proc { |album| !album.new_record? } + + attributes( + :name, + :genres, + ) + + attributes :release, if: proc { |album| album.released? } + def release(album) + album.release_date.strftime('%B %d, %Y') + end + + attributes :special, if: proc { params[:special] } + def special(_album) + params[:special] + end + + many :songs, resource: SongAlba + many :other_songs, resource: SongAlba, if: proc { |album| album.other_songs.present? } +end + +class ModelAlba < BaseAlba + attributes( + :id, + :name, + ) +end diff --git a/spec/support/serializers/album_serializer.rb b/spec/support/serializers/album_serializer.rb index 0cdd28c..da42c3b 100644 --- a/spec/support/serializers/album_serializer.rb +++ b/spec/support/serializers/album_serializer.rb @@ -3,29 +3,27 @@ require_relative 'song_serializer' class AlbumSerializer < Oj::Serializer + transform_keys :camelize + mongo_attributes( :id, :name, :genres, ) - has_many :songs, serializer: SongSerializer - has_many :other_songs, serializer: SongSerializer, if: -> { other_songs.present? } - - attribute \ + attribute if: -> { album.released? } def release album.release_date.strftime('%B %d, %Y') - end, if: -> { album.released? } - - attribute \ - def special - special? - end, if: -> { special? } + end + attribute if: -> { special? }, as: :special def special? memo.fetch(:special) { options[:special] } end + has_many :songs, serializer: SongSerializer + has_many :other_songs, serializer: SongSerializer, if: -> { other_songs.present? } + def other_songs [] end diff --git a/spec/support/serializers/blueprints.rb b/spec/support/serializers/blueprints.rb new file mode 100644 index 0000000..a6cdd2f --- /dev/null +++ b/spec/support/serializers/blueprints.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'blueprinter' +require 'oj_serializers/compat' + +Blueprinter.configure do |config| + config.sort_fields_by = :definition +end + +class SongBlueprint < Blueprinter::Base + field :id, if: ->(_, song, _) { !song.new_record? } + + fields( + :track, + :name, + ) + + field :composers do |song| + song.composer&.split(', ') + end +end + +class AlbumBlueprint < Blueprinter::Base + field :id, if: ->(_, album, _) { !album.new_record? } + + fields( + :name, + :genres, + ) + + field :release, if: ->(_field_name, album, _options) { album.released? } do |album| + album.release_date.strftime('%B %d, %Y') + end + + field :special, if: ->(_, _, options) { options[:special] } do |_, options| + options[:special] + end + + association :songs, blueprint: SongBlueprint + association :other_songs, blueprint: SongBlueprint, if: ->(_, _, _) { false } do + [] + end +end + +class ModelBlueprint < Blueprinter::Base + fields( + :id, + :name, + ) +end diff --git a/spec/support/serializers/model_serializer.rb b/spec/support/serializers/model_serializer.rb index 7a03c55..8f42a07 100644 --- a/spec/support/serializers/model_serializer.rb +++ b/spec/support/serializers/model_serializer.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true class ModelSerializer < Oj::Serializer - # NOTE: ams_attributes is not recommended, it's better to be explicit about - # the strategy. - ams_attributes( + attributes( :id, :name, ) diff --git a/spec/support/serializers/option_serializer.rb b/spec/support/serializers/option_serializer.rb index 4673a77..da543cc 100644 --- a/spec/support/serializers/option_serializer.rb +++ b/spec/support/serializers/option_serializer.rb @@ -2,8 +2,23 @@ require 'oj_serializers' require 'active_model_serializers' +require 'blueprinter' +require 'panko_serializer' +require_relative 'alba' module OptionSerializer + class Alba + include ::Alba::Resource + + attribute :label do |object| + object.attributes['name'] + end + + attribute :value do |object| + object.attributes['_id'] + end + end + class AMS < ActiveModel::Serializer attributes( :label, @@ -19,13 +34,35 @@ def value end end + class Blueprinter < Blueprinter::Base + field :label do |object| + object.attributes['name'] + end + + field :value do |object| + object.attributes['_id'] + end + end + class Oj < Oj::Serializer - attribute \ + attr + def label + @object.attributes['name'] + end + + attr + def value + @object.attributes['_id'] + end + end + + class Panko < Panko::Serializer + attributes(:label, :value) + def label @object.attributes['name'] end - attribute \ def value @object.attributes['_id'] end diff --git a/spec/support/serializers/panko.rb b/spec/support/serializers/panko.rb new file mode 100644 index 0000000..54b8cd6 --- /dev/null +++ b/spec/support/serializers/panko.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require 'panko_serializer' + +Mongoid::Criteria.prepend(Module.new { + def track; end +}) + +Album.prepend(Module.new { + def other_songs + [] + end +}) + +class SongPanko < Panko::Serializer + attributes( + :id, + :track, + :name, + :composers, + ) + + def id + object.id unless object.new_record? + end + + def composers + object.composer&.split(', ') + end + + # NOTE: Panko does not have a way to conditionally not include an attribute. + def self.filters_for(_context, _scope) + { + except: [:id], + } + end +end + +class AlbumPanko < Panko::Serializer + attributes + def id + object.id unless object.new_record? + end + + attributes( + :name, + :genres, + :release, + :special, + ) + + def release + object.release_date.strftime('%B %d, %Y') if object.released? + end + + def special + context[:special] if context + end + + has_many :songs, serializer: SongPanko + has_many :other_songs, serializer: SongPanko + + def other_songs + other_songs if other_songs.present? + end + + # NOTE: Panko does not have a way to conditionally not include an attribute. + def self.filters_for(_context, _scope) + { + except: %i[id other_songs], + } + end +end + +class ModelPanko < Panko::Serializer + attributes( + :id, + :name, + ) +end diff --git a/spec/support/serializers/song_serializer.rb b/spec/support/serializers/song_serializer.rb index 4759085..13f7c00 100644 --- a/spec/support/serializers/song_serializer.rb +++ b/spec/support/serializers/song_serializer.rb @@ -10,10 +10,7 @@ class SongSerializer < Oj::Serializer :name, ) - serializer_attributes( - :composers, - ) - + attribute def composers song.composer&.split(', ') end