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
-
+
-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