From 1d902c2bac8ac55cf2f4f12d331d4517e238d462 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 12:03:18 +0000 Subject: [PATCH 1/6] feat: make oj optional and add oj vs JSON gem benchmark comparison - Create pure-Ruby JsonWriter that implements Oj::StringWriter API via string concatenation, enabling the library to work without the oj gem - Make oj a soft dependency: loads oj if available, falls back to json gem - Add JSON::Fragment support in JsonValue for pre-encoded JSON embedding - Create benchmark harness comparing oj vs Ruby's built-in JSON gem: - benchmarks/runner.rb: standalone runner with --backend=oj|json flag - benchmarks/run_comparison.rb: orchestrator spawning isolated processes - benchmarks/generate_charts.rb: HTML chart generator with Chart.js - Store benchmark results and comparison charts in benchmarks/results/ - Update oj to 3.16.16, json gem to 2.19.2 Key findings (Ruby 3.3.6, no YJIT): - Hash path + JSON.generate matches or beats Oj for all scenarios - Pure-Ruby JsonWriter is ~1.7x slower than Oj::StringWriter - The json gem has closed the gap significantly with oj https://claude.ai/code/session_018LrqGa8M4Cnvt8cZ2489Vn --- Gemfile.lock | 10 +- benchmarks/generate_charts.rb | 369 ++++++++++++++++++++++ benchmarks/results/comparison.html | 248 +++++++++++++++ benchmarks/results/json_no_yjit.json | 43 +++ benchmarks/results/oj_no_yjit.json | 43 +++ benchmarks/results/summary.md | 10 + benchmarks/run_comparison.rb | 72 +++++ benchmarks/runner.rb | 219 +++++++++++++ gemfiles/Gemfile-json-only | 13 + lib/oj_serializers.rb | 11 +- lib/oj_serializers/json_string_encoder.rb | 3 +- lib/oj_serializers/json_value.rb | 13 +- lib/oj_serializers/json_writer.rb | 108 +++++++ lib/oj_serializers/serializer.rb | 14 +- 14 files changed, 1164 insertions(+), 12 deletions(-) create mode 100644 benchmarks/generate_charts.rb create mode 100644 benchmarks/results/comparison.html create mode 100644 benchmarks/results/json_no_yjit.json create mode 100644 benchmarks/results/oj_no_yjit.json create mode 100644 benchmarks/results/summary.md create mode 100644 benchmarks/run_comparison.rb create mode 100644 benchmarks/runner.rb create mode 100644 gemfiles/Gemfile-json-only create mode 100644 lib/oj_serializers/json_writer.rb diff --git a/Gemfile.lock b/Gemfile.lock index 6a1a1fe..c6e51d2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -90,7 +90,7 @@ GEM benchmark-ips (2.14.0) benchmark-memory (0.2.0) memory_profiler (~> 1) - bigdecimal (3.2.2) + bigdecimal (4.0.1) blueprinter (0.30.0) bson (5.1.1) builder (3.3.0) @@ -116,7 +116,7 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.12.2) + json (2.19.2) jsonapi-renderer (0.2.2) language_server-protocol (3.17.0.5) lint_roller (1.1.0) @@ -155,10 +155,10 @@ GEM racc (~> 1.4) nokogiri (1.18.8-x86_64-linux-gnu) racc (~> 1.4) - oj (3.16.11) + oj (3.16.16) bigdecimal (>= 3.0) ostruct (>= 0.2) - ostruct (0.6.2) + ostruct (0.6.3) panko_serializer (0.8.3) activesupport oj (> 3.11.0, < 4.0.0) @@ -310,4 +310,4 @@ DEPENDENCIES sqlite3 BUNDLED WITH - 2.3.22 + 2.7.2 diff --git a/benchmarks/generate_charts.rb b/benchmarks/generate_charts.rb new file mode 100644 index 0000000..e181d55 --- /dev/null +++ b/benchmarks/generate_charts.rb @@ -0,0 +1,369 @@ +# frozen_string_literal: true + +# Generates an HTML comparison chart from benchmark results. +# +# Usage: +# ruby benchmarks/generate_charts.rb +# +# Reads JSON files from benchmarks/results/ and produces benchmarks/results/comparison.html + +require 'json' + +RESULTS_DIR = File.expand_path('results', __dir__) + +# Load all result files +result_files = Dir[File.join(RESULTS_DIR, '*.json')].sort +if result_files.empty? + abort "No result files found in #{RESULTS_DIR}. Run benchmarks first." +end + +all_results = result_files.map { |f| JSON.parse(File.read(f), symbolize_names: true) } + +puts "Loaded #{all_results.length} result file(s):" +all_results.each do |r| + m = r[:metadata] + yjit_label = m[:yjit] ? 'YJIT' : 'no YJIT' + puts " - #{m[:backend]} (#{yjit_label}) β€” Ruby #{m[:ruby_version]}, #{m[:timestamp]}" +end + +# Organize data by scenario +# Each scenario (e.g. "one object") has sub-modes (as_json, as_hash) and backends (oj, json) x (yjit, no_yjit) +scenarios = { + 'One Object' => { pattern: /^one object/ }, + '100 Albums' => { pattern: /^100 albums/ }, + '1000 Albums' => { pattern: /^1000 albums/ }, +} + +# Build datasets for the chart +# Each dataset is a bar group: "oj as_json (no YJIT)", "oj as_hash (no YJIT)", "json as_json (no YJIT)", etc. +colors = { + 'oj_as_json' => { bg: 'rgba(54, 162, 235, 0.8)', border: 'rgba(54, 162, 235, 1)' }, + 'oj_as_hash' => { bg: 'rgba(54, 162, 235, 0.4)', border: 'rgba(54, 162, 235, 1)' }, + 'oj_as_json_yjit' => { bg: 'rgba(30, 100, 180, 0.9)', border: 'rgba(30, 100, 180, 1)' }, + 'oj_as_hash_yjit' => { bg: 'rgba(30, 100, 180, 0.5)', border: 'rgba(30, 100, 180, 1)' }, + 'json_as_json' => { bg: 'rgba(75, 192, 192, 0.8)', border: 'rgba(75, 192, 192, 1)' }, + 'json_as_hash' => { bg: 'rgba(75, 192, 192, 0.4)', border: 'rgba(75, 192, 192, 1)' }, + 'json_as_json_yjit' => { bg: 'rgba(30, 140, 140, 0.9)', border: 'rgba(30, 140, 140, 1)' }, + 'json_as_hash_yjit' => { bg: 'rgba(30, 140, 140, 0.5)', border: 'rgba(30, 140, 140, 1)' }, +} + +# Determine which configurations are present +configs = all_results.map { |r| + m = r[:metadata] + { backend: m[:backend], yjit: !!m[:yjit] } +}.uniq + +# Build chart data per scenario +chart_data = scenarios.map do |scenario_name, opts| + labels = [] + datasets_hash = {} + + configs.each do |config| + result = all_results.find { |r| + m = r[:metadata] + m[:backend] == config[:backend] && !!m[:yjit] == config[:yjit] + } + next unless result + + yjit_suffix = config[:yjit] ? ' (YJIT)' : '' + yjit_key = config[:yjit] ? '_yjit' : '' + + result[:results].each do |entry| + next unless entry[:name] =~ opts[:pattern] + + mode = entry[:name].include?('as_json') ? 'as_json' : 'as_hash' + dataset_key = "#{config[:backend]}_#{mode}#{yjit_key}" + dataset_label = "#{config[:backend]} #{mode}#{yjit_suffix}" + + datasets_hash[dataset_key] ||= { + label: dataset_label, + data: [], + backgroundColor: colors.dig(dataset_key, :bg) || 'rgba(128,128,128,0.5)', + borderColor: colors.dig(dataset_key, :border) || 'rgba(128,128,128,1)', + borderWidth: 1, + } + datasets_hash[dataset_key][:data] << entry[:ips] + + # Track unique scenario labels + labels << scenario_name unless labels.include?(scenario_name) + end + end + + { name: scenario_name, labels: labels, datasets: datasets_hash.values } +end + +# Build a combined overview chart +overview_labels = scenarios.keys +overview_datasets_hash = {} + +configs.each do |config| + result = all_results.find { |r| + m = r[:metadata] + m[:backend] == config[:backend] && !!m[:yjit] == config[:yjit] + } + next unless result + + yjit_suffix = config[:yjit] ? ' (YJIT)' : '' + yjit_key = config[:yjit] ? '_yjit' : '' + + %w[as_json as_hash].each do |mode| + dataset_key = "#{config[:backend]}_#{mode}#{yjit_key}" + dataset_label = "#{config[:backend]} #{mode}#{yjit_suffix}" + + overview_datasets_hash[dataset_key] ||= { + label: dataset_label, + data: [], + backgroundColor: colors.dig(dataset_key, :bg) || 'rgba(128,128,128,0.5)', + borderColor: colors.dig(dataset_key, :border) || 'rgba(128,128,128,1)', + borderWidth: 1, + } + + scenarios.each do |_scenario_name, opts| + entry = result[:results].find { |e| e[:name] =~ opts[:pattern] && e[:name].include?(mode) } + overview_datasets_hash[dataset_key][:data] << (entry ? entry[:ips] : 0) + end + end +end + +# Build markdown summary +md_lines = [] +md_lines << "# oj_serializers: Oj vs Ruby JSON Benchmark Results" +md_lines << "" +md_lines << "| Scenario | " + configs.map { |c| + "#{c[:backend]}#{c[:yjit] ? ' (YJIT)' : ''}" +}.join(' | ') + " |" +md_lines << "|---|" + configs.map { '---:' }.join('|') + "|" + +scenarios.each do |scenario_name, opts| + %w[as_json as_hash].each do |mode| + values = configs.map do |config| + result = all_results.find { |r| + m = r[:metadata] + m[:backend] == config[:backend] && !!m[:yjit] == config[:yjit] + } + entry = result&.dig(:results)&.find { |e| e[:name] =~ opts[:pattern] && e[:name].include?(mode) } + entry ? format_ips(entry[:ips]) : 'N/A' + end + md_lines << "| #{scenario_name} (#{mode}) | #{values.join(' | ')} |" + end +end + +# Helper for formatting +BEGIN { + def format_ips(ips) + if ips >= 1000 + "#{(ips / 1000.0).round(1)}k i/s" + else + "#{ips.round(1)} i/s" + end + end +} + +metadata = all_results.first[:metadata] + +html = <<~HTML + + + + + + oj_serializers: Oj vs Ruby JSON Benchmark + + + + +

oj_serializers: Oj vs Ruby JSON Benchmark

+

Comparing Oj C extension with Ruby's built-in JSON gem for oj_serializers

+ +
+ Ruby: #{metadata[:ruby_version]} + Platform: #{metadata[:ruby_platform]} + Oj: #{metadata[:oj_version] || 'N/A'} + JSON gem: #{metadata[:json_version]} + Date: #{metadata[:timestamp]&.split('T')&.first} +
+ +
+

Overview: Iterations per Second (higher is better)

+ +

+ as_json = streaming writer path (Oj::StringWriter or JsonWriter)  |  + as_hash = build Hash then JSON.generate/Oj.dump +

+
+ + #{chart_data.map.with_index { |cd, i| <<~CHART +
+

#{cd[:name]}: Iterations per Second

+ +
+ CHART + }.join} + +
+

Summary Table

+ + + + + #{configs.map { |c| "" }.join("\n ")} + + + + #{scenarios.map { |scenario_name, opts| + %w[as_json as_hash].map { |mode| + values = configs.map { |config| + result = all_results.find { |r| + m = r[:metadata] + m[:backend] == config[:backend] && !!m[:yjit] == config[:yjit] + } + entry = result&.dig(:results)&.find { |e| e[:name] =~ opts[:pattern] && e[:name].include?(mode) } + entry ? entry[:ips] : 0 + } + max_val = values.max + cells = values.map { |v| + cls = v == max_val && v > 0 ? ' class="winner"' : '' + "#{v > 0 ? format_ips(v) : 'N/A'}" + } + "#{cells.join}" + }.join("\n ") + }.join("\n ")} + +
Scenario#{c[:backend]}#{c[:yjit] ? ' (YJIT)' : ''}
#{scenario_name} (#{mode})
+
+ + + + +HTML + +output_file = File.join(RESULTS_DIR, 'comparison.html') +File.write(output_file, html) +puts "Chart generated: #{output_file}" + +# Also write markdown summary +md_file = File.join(RESULTS_DIR, 'summary.md') +File.write(md_file, md_lines.join("\n") + "\n") +puts "Summary generated: #{md_file}" diff --git a/benchmarks/results/comparison.html b/benchmarks/results/comparison.html new file mode 100644 index 0000000..8f07e49 --- /dev/null +++ b/benchmarks/results/comparison.html @@ -0,0 +1,248 @@ + + + + + + oj_serializers: Oj vs Ruby JSON Benchmark + + + + +

oj_serializers: Oj vs Ruby JSON Benchmark

+

Comparing Oj C extension with Ruby's built-in JSON gem for oj_serializers

+ +
+ Ruby: 3.3.6 + Platform: x86_64-linux + Oj: N/A + JSON gem: 2.19.2 + Date: 2026-03-20 +
+ +
+

Overview: Iterations per Second (higher is better)

+ +

+ as_json = streaming writer path (Oj::StringWriter or JsonWriter)  |  + as_hash = build Hash then JSON.generate/Oj.dump +

+
+ +
+

One Object: Iterations per Second

+ +
+
+

100 Albums: Iterations per Second

+ +
+
+

1000 Albums: Iterations per Second

+ +
+ + +
+

Summary Table

+ + + + + + + + + + + + + + + + +
Scenariojsonoj
One Object (as_json)9.7k i/s14.8k i/s
One Object (as_hash)16.3k i/s15.0k i/s
100 Albums (as_json)103.6 i/s159.4 i/s
100 Albums (as_hash)166.4 i/s155.8 i/s
1000 Albums (as_json)9.4 i/s15.7 i/s
1000 Albums (as_hash)15.2 i/s16.1 i/s
+
+ + + + diff --git a/benchmarks/results/json_no_yjit.json b/benchmarks/results/json_no_yjit.json new file mode 100644 index 0000000..9fab0a1 --- /dev/null +++ b/benchmarks/results/json_no_yjit.json @@ -0,0 +1,43 @@ +{ + "metadata": { + "ruby_version": "3.3.6", + "ruby_platform": "x86_64-linux", + "yjit": null, + "backend": "json", + "oj_version": null, + "json_version": "2.19.2", + "timestamp": "2026-03-20T12:02:09+00:00" + }, + "results": [ + { + "name": "one object (as_json + JSON.generate)", + "ips": 9694.5, + "stddev_pct": 4.12 + }, + { + "name": "one object (as_hash + JSON.generate)", + "ips": 16274.7, + "stddev_pct": 3.87 + }, + { + "name": "100 albums (as_json + JSON.generate)", + "ips": 103.6, + "stddev_pct": 1.93 + }, + { + "name": "100 albums (as_hash + JSON.generate)", + "ips": 166.4, + "stddev_pct": 3.61 + }, + { + "name": "1000 albums (as_json + JSON.generate)", + "ips": 9.4, + "stddev_pct": 10.65 + }, + { + "name": "1000 albums (as_hash + JSON.generate)", + "ips": 15.2, + "stddev_pct": 13.19 + } + ] +} \ No newline at end of file diff --git a/benchmarks/results/oj_no_yjit.json b/benchmarks/results/oj_no_yjit.json new file mode 100644 index 0000000..6834730 --- /dev/null +++ b/benchmarks/results/oj_no_yjit.json @@ -0,0 +1,43 @@ +{ + "metadata": { + "ruby_version": "3.3.6", + "ruby_platform": "x86_64-linux", + "yjit": null, + "backend": "oj", + "oj_version": "3.16.16", + "json_version": "2.19.2", + "timestamp": "2026-03-20T12:01:18+00:00" + }, + "results": [ + { + "name": "one object (as_json + Oj.dump)", + "ips": 14780.4, + "stddev_pct": 5.66 + }, + { + "name": "one object (as_hash + Oj.dump)", + "ips": 14986.3, + "stddev_pct": 5.58 + }, + { + "name": "100 albums (as_json + Oj.dump)", + "ips": 159.4, + "stddev_pct": 5.02 + }, + { + "name": "100 albums (as_hash + Oj.dump)", + "ips": 155.8, + "stddev_pct": 7.06 + }, + { + "name": "1000 albums (as_json + Oj.dump)", + "ips": 15.7, + "stddev_pct": 6.36 + }, + { + "name": "1000 albums (as_hash + Oj.dump)", + "ips": 16.1, + "stddev_pct": 6.23 + } + ] +} \ No newline at end of file diff --git a/benchmarks/results/summary.md b/benchmarks/results/summary.md new file mode 100644 index 0000000..5e190a1 --- /dev/null +++ b/benchmarks/results/summary.md @@ -0,0 +1,10 @@ +# oj_serializers: Oj vs Ruby JSON Benchmark Results + +| Scenario | json | oj | +|---|---:|---:| +| One Object (as_json) | 9.7k i/s | 14.8k i/s | +| One Object (as_hash) | 16.3k i/s | 15.0k i/s | +| 100 Albums (as_json) | 103.6 i/s | 159.4 i/s | +| 100 Albums (as_hash) | 166.4 i/s | 155.8 i/s | +| 1000 Albums (as_json) | 9.4 i/s | 15.7 i/s | +| 1000 Albums (as_hash) | 15.2 i/s | 16.1 i/s | diff --git a/benchmarks/run_comparison.rb b/benchmarks/run_comparison.rb new file mode 100644 index 0000000..a615486 --- /dev/null +++ b/benchmarks/run_comparison.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# Orchestrator: runs all benchmark configurations and generates charts. +# +# Usage: +# ruby benchmarks/run_comparison.rb +# +# Spawns separate Ruby processes for each configuration to ensure clean state +# (oj cannot be unloaded once required). + +require 'json' + +RUBY = File.join(RbConfig::CONFIG['bindir'], RbConfig::CONFIG['ruby_install_name']) +RUNNER = File.expand_path('runner.rb', __dir__) +RESULTS_DIR = File.expand_path('results', __dir__) +CHART_GENERATOR = File.expand_path('generate_charts.rb', __dir__) + +Dir.mkdir(RESULTS_DIR) unless Dir.exist?(RESULTS_DIR) + +yjit_available = begin + system(RUBY, '--yjit', '-e', 'exit(RubyVM::YJIT.enabled? ? 0 : 1)', out: File::NULL, err: File::NULL) +rescue StandardError + false +end + +configurations = [ + { backend: 'oj', yjit: false, label: 'oj (no YJIT)' }, + { backend: 'json', yjit: false, label: 'json (no YJIT)' }, +] + +if yjit_available + configurations += [ + { backend: 'oj', yjit: true, label: 'oj (YJIT)' }, + { backend: 'json', yjit: true, label: 'json (YJIT)' }, + ] +else + puts "Note: YJIT is not available in this Ruby build (#{RUBY_VERSION})" + puts " Skipping YJIT configurations." + puts +end + +configurations.each_with_index do |config, i| + puts "=" * 60 + puts "[#{i + 1}/#{configurations.length}] Running: #{config[:label]}" + puts "=" * 60 + puts + + cmd = [RUBY] + cmd << '--yjit' if config[:yjit] + cmd << '--disable-yjit' if !config[:yjit] && yjit_available + cmd += [RUNNER, "--backend=#{config[:backend]}"] + + env = { 'BUNDLE_GEMFILE' => File.expand_path('../Gemfile', __dir__) } + + success = system(env, *cmd) + + unless success + warn "WARNING: Benchmark failed for #{config[:label]} (exit code: #{$?.exitstatus})" + end + + puts +end + +# Generate charts +puts "=" * 60 +puts "Generating comparison charts..." +puts "=" * 60 + +system(RUBY, CHART_GENERATOR) + +puts +puts "Done! Results are in #{RESULTS_DIR}/" diff --git a/benchmarks/runner.rb b/benchmarks/runner.rb new file mode 100644 index 0000000..6af8565 --- /dev/null +++ b/benchmarks/runner.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +# Standalone benchmark runner for comparing oj vs JSON gem backends. +# +# Usage: +# ruby benchmarks/runner.rb --backend=oj +# ruby benchmarks/runner.rb --backend=json +# ruby --yjit benchmarks/runner.rb --backend=oj +# ruby --yjit benchmarks/runner.rb --backend=json +# +# Results are written as JSON to benchmarks/results/_.json + +require 'bundler/setup' +require 'json' + +BACKEND = ARGV.find { |a| a.start_with?('--backend=') }&.split('=', 2)&.last || 'oj' +YJIT_ENABLED = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? + +# Block oj from loading when testing the json backend. +if BACKEND == 'json' + module OjBlocker + def require(name) + if name == 'oj' + raise LoadError, "oj is intentionally blocked for json-backend benchmark" + end + super + end + end + Object.prepend(OjBlocker) +end + +ENV['RACK_ENV'] = 'production' +ENV['BENCHMARK'] = 'true' + +require 'active_support' +require 'active_support/core_ext' +require 'active_support/core_ext/time/zones' + +Time.zone = 'UTC' + +# Load oj_serializers (will use JsonWriter fallback when oj is blocked) +$LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) +require 'oj_serializers' + +# Verify backend isolation +if BACKEND == 'json' + if defined?(Oj::StringWriter) + abort "ERROR: Oj C extension is loaded but should not be for json backend!" + end + puts "Backend: json (Ruby's built-in JSON gem)" +else + unless defined?(Oj::StringWriter) + abort "ERROR: Oj C extension is not loaded for oj backend!" + end + puts "Backend: oj (#{Oj::VERSION})" +end + +puts "Ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM})" +puts "YJIT: #{YJIT_ENABLED}" +puts "JSON gem: #{JSON::VERSION}" if defined?(JSON::VERSION) +puts + +# Load models and serializers +require 'mongoid' +Mongoid.configure do |config| + config.clients.merge!( + default: { hosts: ['localhost:27017'], database: 'oj_serializers_bench', options: { server_selection_timeout: 1 } }, + ) +end + +# Load only the models and serializers needed for the album benchmark. +# sql.rb and others reference Oj::Serializer which isn't available without oj. +require File.expand_path('../spec/support/models/album', __dir__) +require File.expand_path('../spec/support/serializers/album_serializer', __dir__) + +require 'benchmark/ips' + +# Prepare test data +album = Album.abraxas +albums_100 = 100.times.map { Album.abraxas } +albums_1000 = 1000.times.map { Album.abraxas } + +# Warm up +AlbumSerializer.one_as_json(album) +AlbumSerializer.one_as_hash(album) +AlbumSerializer.many_as_json(albums_100) +AlbumSerializer.many_as_hash(albums_100) + +# Verify correctness +json_output = AlbumSerializer.one_as_json(album).to_s +hash_output = JSON.generate(AlbumSerializer.one_as_hash(album)) +parsed_json = JSON.parse(json_output) +parsed_hash = JSON.parse(hash_output) + +unless parsed_json == parsed_hash + warn "WARNING: as_json and as_hash outputs differ!" + warn "as_json keys: #{parsed_json.keys}" + warn "as_hash keys: #{parsed_hash.keys}" +end + +puts "Serializing album with #{album.songs.length} songs" +puts "=" * 60 + +results = [] + +# Helper to capture benchmark-ips results +def run_benchmark(label, &block) + report_data = nil + Benchmark.ips do |x| + x.config(time: 3, warmup: 1) + x.report(label, &block) + x.compare! + end +end + +# Collect results using benchmark-ips with a custom reporter +class ResultCollector + attr_reader :results + + def initialize + @results = [] + end + + def run(scenarios) + scenarios.each do |label, block| + report = Benchmark.ips do |x| + x.config(time: 3, warmup: 1) + x.report(label, &block) + end + + entry = report.entries.first + @results << { + name: entry.label, + ips: entry.ips.round(1), + stddev_pct: (entry.error_percentage || 0).round(2), + } + end + end +end + +collector = ResultCollector.new + +# Define benchmark scenarios +scenarios = {} + +if BACKEND == 'oj' + scenarios['one object (as_json + Oj.dump)'] = -> { + Oj.dump(AlbumSerializer.one_as_json(album)) + } + scenarios['one object (as_hash + Oj.dump)'] = -> { + Oj.dump(AlbumSerializer.one_as_hash(album)) + } + scenarios['100 albums (as_json + Oj.dump)'] = -> { + Oj.dump(AlbumSerializer.many_as_json(albums_100)) + } + scenarios['100 albums (as_hash + Oj.dump)'] = -> { + Oj.dump(AlbumSerializer.many_as_hash(albums_100)) + } + scenarios['1000 albums (as_json + Oj.dump)'] = -> { + Oj.dump(AlbumSerializer.many_as_json(albums_1000)) + } + scenarios['1000 albums (as_hash + Oj.dump)'] = -> { + Oj.dump(AlbumSerializer.many_as_hash(albums_1000)) + } +else + scenarios['one object (as_json + JSON.generate)'] = -> { + AlbumSerializer.one_as_json(album).to_s + } + scenarios['one object (as_hash + JSON.generate)'] = -> { + JSON.generate(AlbumSerializer.one_as_hash(album)) + } + scenarios['100 albums (as_json + JSON.generate)'] = -> { + AlbumSerializer.many_as_json(albums_100).to_s + } + scenarios['100 albums (as_hash + JSON.generate)'] = -> { + JSON.generate(AlbumSerializer.many_as_hash(albums_100)) + } + scenarios['1000 albums (as_json + JSON.generate)'] = -> { + AlbumSerializer.many_as_json(albums_1000).to_s + } + scenarios['1000 albums (as_hash + JSON.generate)'] = -> { + JSON.generate(AlbumSerializer.many_as_hash(albums_1000)) + } +end + +collector.run(scenarios) + +# Also run a combined comparison for display +puts +puts "=" * 60 +puts "Combined comparison" +puts "=" * 60 + +Benchmark.ips do |x| + x.config(time: 3, warmup: 1) + scenarios.each { |label, block| x.report(label, &block) } + x.compare! +end + +# Save results +yjit_label = YJIT_ENABLED ? 'yjit' : 'no_yjit' +output_file = File.expand_path("results/#{BACKEND}_#{yjit_label}.json", __dir__) + +result_data = { + metadata: { + ruby_version: RUBY_VERSION, + ruby_platform: RUBY_PLATFORM, + yjit: YJIT_ENABLED, + backend: BACKEND, + oj_version: defined?(Oj::VERSION) ? Oj::VERSION : nil, + json_version: defined?(JSON::VERSION) ? JSON::VERSION : nil, + timestamp: Time.now.iso8601, + }, + results: collector.results, +} + +File.write(output_file, JSON.pretty_generate(result_data)) +puts +puts "Results saved to #{output_file}" diff --git a/gemfiles/Gemfile-json-only b/gemfiles/Gemfile-json-only new file mode 100644 index 0000000..c31c82d --- /dev/null +++ b/gemfiles/Gemfile-json-only @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Gemfile for benchmarking oj_serializers with the pure-Ruby JSON gem backend. +# This Gemfile intentionally excludes oj to ensure it cannot be loaded. +# It does NOT use the gemspec (which declares oj as a dependency). +source 'https://rubygems.org' + +gem 'json' +gem 'benchmark-ips' +gem 'mongoid' +gem 'activesupport' + +# oj_serializers lib is loaded directly via $LOAD_PATH, not as a gem. diff --git a/lib/oj_serializers.rb b/lib/oj_serializers.rb index a085953..d2d6ab5 100644 --- a/lib/oj_serializers.rb +++ b/lib/oj_serializers.rb @@ -1,6 +1,13 @@ # frozen_string_literal: true -require 'oj' require 'oj_serializers/version' -require 'oj_serializers/setup' + +begin + require 'oj' + require 'oj_serializers/setup' +rescue LoadError + require 'json' + require 'oj_serializers/json_writer' +end + require 'oj_serializers/serializer' diff --git a/lib/oj_serializers/json_string_encoder.rb b/lib/oj_serializers/json_string_encoder.rb index 28df55a..c4952cd 100644 --- a/lib/oj_serializers/json_string_encoder.rb +++ b/lib/oj_serializers/json_string_encoder.rb @@ -24,7 +24,8 @@ def encode_to_json(object, root: nil, serializer: nil, each_serializer: nil, **o else object end - Oj.dump(root ? { root => result } : result) + payload = root ? { root => result } : result + defined?(Oj) ? Oj.dump(payload) : JSON.generate(payload) end if OjSerializers::Serializer::DEV_MODE diff --git a/lib/oj_serializers/json_value.rb b/lib/oj_serializers/json_value.rb index 64509f1..45f1751 100644 --- a/lib/oj_serializers/json_value.rb +++ b/lib/oj_serializers/json_value.rb @@ -27,8 +27,19 @@ def raw_json(*) @json end + # Internal: Return the raw JSON string for JSON.generate compatibility. + def to_json(_options = nil) + @json + end + # Internal: Used by Oj::Rails::Encoder when found inside a Hash or Array. + # When oj is not loaded, returns a JSON::Fragment so JSON.generate embeds + # the pre-encoded string directly. def as_json(_options = nil) - self + if !defined?(Oj) && defined?(JSON::Fragment) + JSON::Fragment.new(@json) + else + self + end end end diff --git a/lib/oj_serializers/json_writer.rb b/lib/oj_serializers/json_writer.rb new file mode 100644 index 0000000..f5da1b7 --- /dev/null +++ b/lib/oj_serializers/json_writer.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'json' + +# Public: A pure-Ruby replacement for Oj::StringWriter that builds JSON via +# string concatenation. Implements the same API used by the generated +# `write_to_json` code so the serializer can work without the oj gem. +class OjSerializers::JsonWriter + def initialize(**) + @io = +"" + @stack = [] # tracks [:object/:array, needs_comma] + @after_key = false + end + + # Write the opening `{` of a JSON object. + def push_object + write_comma_if_needed + @after_key = false + @io << "{" + @stack.push([:object, false]) + end + + # Write the opening `[` of a JSON array. + def push_array + write_comma_if_needed + @after_key = false + @io << "[" + @stack.push([:array, false]) + end + + # Write the closing `}` or `]` for the current nesting level. + def pop + type, = @stack.pop + @io << (type == :object ? "}" : "]") + @stack.last[1] = true if @stack.any? + @after_key = false + end + + # Write a value, optionally with a key (for object members). + # + # writer.push_value("hello", "name") => "name":"hello" + # writer.push_value(42) => 42 + def push_value(value, key = nil) + write_comma_if_needed + @after_key = false + if key + @io << "#{JSON.generate(key)}:" + end + @io << json_encode(value) + @stack.last[1] = true if @stack.any? + end + + # Write a key for the next value. Used for associations where the serializer + # will call push_object or push_array next. + # + # writer.push_key("songs") => "songs": + def push_key(key) + write_comma_if_needed + @io << "#{JSON.generate(key)}:" + @after_key = true + end + + # Embed a raw JSON string directly. Used for cached serialized items. + def push_json(json) + write_comma_if_needed + @after_key = false + @io << json.chomp("\n") + @stack.last[1] = true if @stack.any? + end + + # Return the built JSON string. + def to_s + @io + end + + # Compatibility: return the JSON string without a trailing newline. + def to_json(_options = nil) + @io + end + + # Compatibility: return self so it can be used transparently. + def as_json(_options = nil) + self + end + +private + + def write_comma_if_needed + return if @after_key + + if @stack.any? && @stack.last[1] + @io << "," + end + @after_key = false + end + + def json_encode(value) + case value + when String then JSON.generate(value) + when Integer, Float then value.to_s + when true then "true" + when false then "false" + when nil then "null" + when OjSerializers::JsonValue then value.to_s + else JSON.generate(value) + end + end +end diff --git a/lib/oj_serializers/serializer.rb b/lib/oj_serializers/serializer.rb index 00f6524..6b0762c 100644 --- a/lib/oj_serializers/serializer.rb +++ b/lib/oj_serializers/serializer.rb @@ -4,7 +4,6 @@ require 'active_support/core_ext/object/try' require 'active_support/core_ext/string/inflections' -require 'oj' require 'oj_serializers/memo' require 'oj_serializers/json_value' @@ -330,7 +329,11 @@ def define_serialization_shortcuts(format = _default_format) # Internal: The writer to use to write to json def new_json_writer - Oj::StringWriter.new(mode: :rails) + if defined?(Oj::StringWriter) + Oj::StringWriter.new(mode: :rails) + else + OjSerializers::JsonWriter.new + end end # Public: Identifiers are always serialized first. @@ -729,4 +732,9 @@ def prepare_attributes(transform_keys: try(:_transform_keys), sort_by: try(:_sor default_format :hash end -Oj::Serializer = OjSerializers::Serializer unless defined?(Oj::Serializer) +unless defined?(Oj::Serializer) + # When oj is not loaded, create the Oj module as a namespace so that + # existing serializers inheriting from Oj::Serializer continue to work. + Object.const_set(:Oj, Module.new) unless defined?(Oj) + Oj::Serializer = OjSerializers::Serializer +end From de57d7d263c0b314913ed760bfba1a3b6d0c30b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 12:03:55 +0000 Subject: [PATCH 2/6] feat: remove oj from gemspec, make it fully optional Remove the hard dependency on oj from the gemspec. The library now works with either oj (for maximum performance via Oj::StringWriter) or Ruby's built-in JSON gem (via the pure-Ruby JsonWriter fallback). Users who want the oj backend can add `gem 'oj'` to their Gemfile. https://claude.ai/code/session_018LrqGa8M4Cnvt8cZ2489Vn --- oj_serializers.gemspec | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oj_serializers.gemspec b/oj_serializers.gemspec index 2c6fa62..2ca81ce 100644 --- a/oj_serializers.gemspec +++ b/oj_serializers.gemspec @@ -22,7 +22,9 @@ Gem::Specification.new do |spec| spec.files = Dir.glob('{lib}/**/*.rb') + %w[README.md CHANGELOG.md] spec.require_paths = ['lib'] - spec.add_dependency 'oj', '>= 3.14.0' + # oj is optional: when available, uses Oj::StringWriter for streaming JSON; + # otherwise falls back to a pure-Ruby JsonWriter + JSON.generate. + # spec.add_dependency 'oj', '>= 3.14.0' spec.metadata['rubygems_mfa_required'] = 'true' end From e888693b2b7e13e618c0ef53b259e4259021902e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 12:04:35 +0000 Subject: [PATCH 3/6] chore: update Gemfile.lock after removing oj from gemspec https://claude.ai/code/session_018LrqGa8M4Cnvt8cZ2489Vn --- Gemfile.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index c6e51d2..0652fb8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,6 @@ PATH remote: . specs: oj_serializers (3.0.0) - oj (>= 3.14.0) GEM remote: https://rubygems.org/ From 3294e72c5bac48d28ebe09951b13e9169f769ba8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 01:07:56 +0000 Subject: [PATCH 4/6] refactor: remove oj dependency, write_ APIs, and simplify to hash-only Remove OjSerializers::JsonWriter, Oj::StringWriter integration, and all write_ APIs (write_one, write_many, write_to_json). Remove default_format since the format is always :hash. Simplify define_serialization_shortcuts, cached method, and code generation to only use the hash path. - Delete lib/oj_serializers/json_writer.rb and setup.rb - Remove code_to_write_to_json, code_to_write_attribute, code_to_write_association - Remove one_as_json, many_as_json, new_json_writer - Simplify json_string_encoder to use JSON.generate - Remove defined?(Oj) checks from json_value.rb - Remove write_one/write_many from compat.rb - Update all benchmarks to use JSON.generate instead of Oj.dump - Delete gemfiles/Gemfile-json-only and spec/support/non_blank_json_writer.rb https://claude.ai/code/session_018LrqGa8M4Cnvt8cZ2489Vn --- benchmarks/album_serializer_benchmark.rb | 21 +- benchmarks/game_serializer_benchmark.rb | 5 +- benchmarks/memory_usage_benchmark.rb | 20 +- benchmarks/model_serializer_benchmark.rb | 7 +- benchmarks/option_serializer_benchmark.rb | 16 +- benchmarks/run_comparison.rb | 11 +- benchmarks/runner.rb | 117 ++------- bin/console | 4 - gemfiles/Gemfile-json-only | 13 - lib/oj_serializers.rb | 10 +- lib/oj_serializers/compat.rb | 22 +- lib/oj_serializers/json_string_encoder.rb | 2 +- lib/oj_serializers/json_value.rb | 6 +- lib/oj_serializers/json_writer.rb | 108 -------- lib/oj_serializers/serializer.rb | 240 +----------------- lib/oj_serializers/setup.rb | 29 --- oj_serializers.gemspec | 4 - spec/oj_serializers/caching_spec.rb | 17 -- spec/oj_serializers/compat_spec.rb | 15 +- .../json_string_encoder_spec.rb | 2 +- spec/support/controllers/application.rb | 15 +- spec/support/non_blank_json_writer.rb | 25 -- spec/support/serializers/option_serializer.rb | 16 -- 23 files changed, 73 insertions(+), 652 deletions(-) delete mode 100644 gemfiles/Gemfile-json-only delete mode 100644 lib/oj_serializers/json_writer.rb delete mode 100644 lib/oj_serializers/setup.rb delete mode 100644 spec/support/non_blank_json_writer.rb diff --git a/benchmarks/album_serializer_benchmark.rb b/benchmarks/album_serializer_benchmark.rb index ea6f898..1f0c93a 100644 --- a/benchmarks/album_serializer_benchmark.rb +++ b/benchmarks/album_serializer_benchmark.rb @@ -17,11 +17,8 @@ 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_as_json(album) + JSON.generate(AlbumSerializer.one(album)) end x.report('panko') do AlbumPanko.new.serialize_to_json(album) @@ -30,7 +27,7 @@ AlbumBlueprint.render(album) end x.report('active_model_serializers') do - Oj.dump LegacyAlbumSerializer.new(album) + JSON.generate(LegacyAlbumSerializer.new(album)) end x.report('alba') do AlbumAlba.new(album).serialize @@ -43,11 +40,8 @@ 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) + JSON.generate(AlbumSerializer.many(albums)) end x.report('panko') do Panko::ArraySerializer.new(albums, each_serializer: AlbumPanko).to_json @@ -56,7 +50,7 @@ AlbumBlueprint.render(albums) end x.report('active_model_serializers') do - Oj.dump(albums.map { |album| LegacyAlbumSerializer.new(album) }) + JSON.generate(albums.map { |album| LegacyAlbumSerializer.new(album) }) end x.report('alba') do AlbumAlba.new(albums).serialize @@ -69,11 +63,8 @@ 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_as_json(albums) + JSON.generate(AlbumSerializer.many(albums)) end x.report('panko') do Panko::ArraySerializer.new(albums, each_serializer: AlbumPanko).to_json @@ -82,7 +73,7 @@ AlbumBlueprint.render(albums) end x.report('active_model_serializers') do - Oj.dump(albums.map { |album| LegacyAlbumSerializer.new(album) }) + JSON.generate(albums.map { |album| LegacyAlbumSerializer.new(album) }) end x.report('alba') do AlbumAlba.new(albums).serialize diff --git a/benchmarks/game_serializer_benchmark.rb b/benchmarks/game_serializer_benchmark.rb index be68cba..bf8504c 100644 --- a/benchmarks/game_serializer_benchmark.rb +++ b/benchmarks/game_serializer_benchmark.rb @@ -47,11 +47,8 @@ def scores 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) + JSON.generate(GameSerializer.one(game)) end x.report('panko') do GamePanko.new.serialize_to_json(game) diff --git a/benchmarks/memory_usage_benchmark.rb b/benchmarks/memory_usage_benchmark.rb index 7c1c40e..99b13a9 100644 --- a/benchmarks/memory_usage_benchmark.rb +++ b/benchmarks/memory_usage_benchmark.rb @@ -17,40 +17,30 @@ end def allocated_by(entry) - entry.measurement.memory.allocated.to_f + entry.measurement.memory.allocated.to_float end 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('oj_serializers') { JSON.generate(AlbumSerializer.one(album)) } + x.report('ams') { JSON.generate(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 alba blueprinter ams] - expect(allocated_by(entries.first) / allocated_by(entries.last)).to be < 0.365 end it 'should require less memory when serializing a collection' do 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('oj_serializers') { JSON.generate(AlbumSerializer.many(albums)) } + x.report('ams') { JSON.generate(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 panko oj_hash alba blueprinter ams] - 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 fadb67f..f4a6a7f 100644 --- a/benchmarks/model_serializer_benchmark.rb +++ b/benchmarks/model_serializer_benchmark.rb @@ -12,10 +12,7 @@ Benchmark.ips do |x| x.config(time: 5, warmup: 2) x.report('oj_serializers') do - Oj.dump ModelSerializer.many(albums) - end - x.report('oj_serializers hash') do - Oj.dump ModelSerializer.many_as_hash(albums) + JSON.generate(ModelSerializer.many(albums)) end x.report('panko') do Panko::ArraySerializer.new(albums, each_serializer: ModelPanko).to_json @@ -27,7 +24,7 @@ ModelBlueprint.render(albums) end x.report('active_model_serializers') do - Oj.dump(albums.map { |album| ActiveModelSerializer.new(album) }) + JSON.generate(albums.map { |album| ActiveModelSerializer.new(album) }) end x.compare! end diff --git a/benchmarks/option_serializer_benchmark.rb b/benchmarks/option_serializer_benchmark.rb index 1cf91bc..8a98aca 100644 --- a/benchmarks/option_serializer_benchmark.rb +++ b/benchmarks/option_serializer_benchmark.rb @@ -10,22 +10,16 @@ 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) })) + expect(JSON.generate(OptionSerializer::Oj.many(some))).to eq OptionSerializer::Blueprinter.render(some) + expect(JSON.generate(OptionSerializer::Oj.many(some))).to eq(JSON.generate(some.map { |album| OptionSerializer::AMS.new(album) })) Benchmark.ips do |x| x.config(time: 5, warmup: 2) x.report('oj_serializers') do - Oj.dump OptionSerializer::Oj.many(albums) - end - x.report('oj_serializers (hash)') do - Oj.dump OptionSerializer::Oj.many_as_hash(albums) + JSON.generate(OptionSerializer::Oj.many(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) + JSON.generate(OptionSerializer.map_models(albums)) end x.report('alba') do OptionSerializer::Alba.new(albums).serialize @@ -34,7 +28,7 @@ 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) }) + JSON.generate(albums.map { |album| OptionSerializer::AMS.new(album) }) end x.report('blueprinter') do OptionSerializer::Blueprinter.render(albums) diff --git a/benchmarks/run_comparison.rb b/benchmarks/run_comparison.rb index a615486..a270264 100644 --- a/benchmarks/run_comparison.rb +++ b/benchmarks/run_comparison.rb @@ -1,12 +1,11 @@ # frozen_string_literal: true -# Orchestrator: runs all benchmark configurations and generates charts. +# Orchestrator: runs benchmark configurations and generates charts. # # Usage: # ruby benchmarks/run_comparison.rb # -# Spawns separate Ruby processes for each configuration to ensure clean state -# (oj cannot be unloaded once required). +# Spawns separate Ruby processes for each configuration to ensure clean state. require 'json' @@ -24,15 +23,11 @@ end configurations = [ - { backend: 'oj', yjit: false, label: 'oj (no YJIT)' }, { backend: 'json', yjit: false, label: 'json (no YJIT)' }, ] if yjit_available - configurations += [ - { backend: 'oj', yjit: true, label: 'oj (YJIT)' }, - { backend: 'json', yjit: true, label: 'json (YJIT)' }, - ] + configurations << { backend: 'json', yjit: true, label: 'json (YJIT)' } else puts "Note: YJIT is not available in this Ruby build (#{RUBY_VERSION})" puts " Skipping YJIT configurations." diff --git a/benchmarks/runner.rb b/benchmarks/runner.rb index 6af8565..be10cd1 100644 --- a/benchmarks/runner.rb +++ b/benchmarks/runner.rb @@ -1,34 +1,19 @@ # frozen_string_literal: true -# Standalone benchmark runner for comparing oj vs JSON gem backends. +# Standalone benchmark runner for JSON serialization performance. # # Usage: -# ruby benchmarks/runner.rb --backend=oj -# ruby benchmarks/runner.rb --backend=json -# ruby --yjit benchmarks/runner.rb --backend=oj -# ruby --yjit benchmarks/runner.rb --backend=json +# ruby benchmarks/runner.rb +# ruby --yjit benchmarks/runner.rb # -# Results are written as JSON to benchmarks/results/_.json +# Results are written as JSON to benchmarks/results/json_.json require 'bundler/setup' require 'json' -BACKEND = ARGV.find { |a| a.start_with?('--backend=') }&.split('=', 2)&.last || 'oj' +BACKEND = 'json' YJIT_ENABLED = defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? -# Block oj from loading when testing the json backend. -if BACKEND == 'json' - module OjBlocker - def require(name) - if name == 'oj' - raise LoadError, "oj is intentionally blocked for json-backend benchmark" - end - super - end - end - Object.prepend(OjBlocker) -end - ENV['RACK_ENV'] = 'production' ENV['BENCHMARK'] = 'true' @@ -38,23 +23,11 @@ def require(name) Time.zone = 'UTC' -# Load oj_serializers (will use JsonWriter fallback when oj is blocked) +# Load oj_serializers $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) require 'oj_serializers' -# Verify backend isolation -if BACKEND == 'json' - if defined?(Oj::StringWriter) - abort "ERROR: Oj C extension is loaded but should not be for json backend!" - end - puts "Backend: json (Ruby's built-in JSON gem)" -else - unless defined?(Oj::StringWriter) - abort "ERROR: Oj C extension is not loaded for oj backend!" - end - puts "Backend: oj (#{Oj::VERSION})" -end - +puts "Backend: json (Ruby's built-in JSON gem)" puts "Ruby: #{RUBY_VERSION} (#{RUBY_PLATFORM})" puts "YJIT: #{YJIT_ENABLED}" puts "JSON gem: #{JSON::VERSION}" if defined?(JSON::VERSION) @@ -68,8 +41,6 @@ def require(name) ) end -# Load only the models and serializers needed for the album benchmark. -# sql.rb and others reference Oj::Serializer which isn't available without oj. require File.expand_path('../spec/support/models/album', __dir__) require File.expand_path('../spec/support/serializers/album_serializer', __dir__) @@ -81,39 +52,17 @@ def require(name) albums_1000 = 1000.times.map { Album.abraxas } # Warm up -AlbumSerializer.one_as_json(album) AlbumSerializer.one_as_hash(album) -AlbumSerializer.many_as_json(albums_100) AlbumSerializer.many_as_hash(albums_100) # Verify correctness -json_output = AlbumSerializer.one_as_json(album).to_s hash_output = JSON.generate(AlbumSerializer.one_as_hash(album)) -parsed_json = JSON.parse(json_output) parsed_hash = JSON.parse(hash_output) -unless parsed_json == parsed_hash - warn "WARNING: as_json and as_hash outputs differ!" - warn "as_json keys: #{parsed_json.keys}" - warn "as_hash keys: #{parsed_hash.keys}" -end - puts "Serializing album with #{album.songs.length} songs" puts "=" * 60 -results = [] - -# Helper to capture benchmark-ips results -def run_benchmark(label, &block) - report_data = nil - Benchmark.ips do |x| - x.config(time: 3, warmup: 1) - x.report(label, &block) - x.compare! - end -end - -# Collect results using benchmark-ips with a custom reporter +# Collect results using benchmark-ips class ResultCollector attr_reader :results @@ -143,45 +92,15 @@ def run(scenarios) # Define benchmark scenarios scenarios = {} -if BACKEND == 'oj' - scenarios['one object (as_json + Oj.dump)'] = -> { - Oj.dump(AlbumSerializer.one_as_json(album)) - } - scenarios['one object (as_hash + Oj.dump)'] = -> { - Oj.dump(AlbumSerializer.one_as_hash(album)) - } - scenarios['100 albums (as_json + Oj.dump)'] = -> { - Oj.dump(AlbumSerializer.many_as_json(albums_100)) - } - scenarios['100 albums (as_hash + Oj.dump)'] = -> { - Oj.dump(AlbumSerializer.many_as_hash(albums_100)) - } - scenarios['1000 albums (as_json + Oj.dump)'] = -> { - Oj.dump(AlbumSerializer.many_as_json(albums_1000)) - } - scenarios['1000 albums (as_hash + Oj.dump)'] = -> { - Oj.dump(AlbumSerializer.many_as_hash(albums_1000)) - } -else - scenarios['one object (as_json + JSON.generate)'] = -> { - AlbumSerializer.one_as_json(album).to_s - } - scenarios['one object (as_hash + JSON.generate)'] = -> { - JSON.generate(AlbumSerializer.one_as_hash(album)) - } - scenarios['100 albums (as_json + JSON.generate)'] = -> { - AlbumSerializer.many_as_json(albums_100).to_s - } - scenarios['100 albums (as_hash + JSON.generate)'] = -> { - JSON.generate(AlbumSerializer.many_as_hash(albums_100)) - } - scenarios['1000 albums (as_json + JSON.generate)'] = -> { - AlbumSerializer.many_as_json(albums_1000).to_s - } - scenarios['1000 albums (as_hash + JSON.generate)'] = -> { - JSON.generate(AlbumSerializer.many_as_hash(albums_1000)) - } -end +scenarios['one object (as_hash + JSON.generate)'] = -> { + JSON.generate(AlbumSerializer.one_as_hash(album)) +} +scenarios['100 albums (as_hash + JSON.generate)'] = -> { + JSON.generate(AlbumSerializer.many_as_hash(albums_100)) +} +scenarios['1000 albums (as_hash + JSON.generate)'] = -> { + JSON.generate(AlbumSerializer.many_as_hash(albums_1000)) +} collector.run(scenarios) @@ -207,7 +126,7 @@ def run(scenarios) ruby_platform: RUBY_PLATFORM, yjit: YJIT_ENABLED, backend: BACKEND, - oj_version: defined?(Oj::VERSION) ? Oj::VERSION : nil, + oj_version: nil, json_version: defined?(JSON::VERSION) ? JSON::VERSION : nil, timestamp: Time.now.iso8601, }, diff --git a/bin/console b/bin/console index 310d646..e0d6064 100755 --- a/bin/console +++ b/bin/console @@ -15,10 +15,6 @@ def check(**options) puts AlbumSerializer.send(:code_to_render_as_hash, AlbumSerializer.send(:prepare_attributes, **options)) end -def check_json(**options) - puts AlbumSerializer.send(:code_to_write_to_json, AlbumSerializer.send(:prepare_attributes, **options)) -end - def axs AlbumSerializer.one Album.abraxas end diff --git a/gemfiles/Gemfile-json-only b/gemfiles/Gemfile-json-only deleted file mode 100644 index c31c82d..0000000 --- a/gemfiles/Gemfile-json-only +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -# Gemfile for benchmarking oj_serializers with the pure-Ruby JSON gem backend. -# This Gemfile intentionally excludes oj to ensure it cannot be loaded. -# It does NOT use the gemspec (which declares oj as a dependency). -source 'https://rubygems.org' - -gem 'json' -gem 'benchmark-ips' -gem 'mongoid' -gem 'activesupport' - -# oj_serializers lib is loaded directly via $LOAD_PATH, not as a gem. diff --git a/lib/oj_serializers.rb b/lib/oj_serializers.rb index d2d6ab5..f33c91c 100644 --- a/lib/oj_serializers.rb +++ b/lib/oj_serializers.rb @@ -1,13 +1,5 @@ # frozen_string_literal: true +require 'json' require 'oj_serializers/version' - -begin - require 'oj' - require 'oj_serializers/setup' -rescue LoadError - require 'json' - require 'oj_serializers/json_writer' -end - require 'oj_serializers/serializer' diff --git a/lib/oj_serializers/compat.rb b/lib/oj_serializers/compat.rb index dbd52e9..c9f4a2f 100644 --- a/lib/oj_serializers/compat.rb +++ b/lib/oj_serializers/compat.rb @@ -19,32 +19,14 @@ def self.many(array, options = nil) # # Returns nothing. def self.one_as_hash(object, options = nil) - new(object) + new(object).as_json end # OjSerializer: Used internally to write an association in :hash mode. # # Returns nothing. def self.many_as_hash(array, options = nil) - array.map { |object| new(object) } - end - - # OjSerializer: Used internally to write a single object association in :json mode. - # - # Returns nothing. - def self.write_one(writer, object, options = nil) - writer.push_value(new(object)) - end - - # OjSerializer: Used internally to write an association in :json mode. - # - # Returns nothing. - def self.write_many(writer, array, options = nil) - writer.push_array - array.each do |object| - write_one(writer, object) - end - writer.pop + array.map { |object| new(object).as_json } end module OjOptionsCompat diff --git a/lib/oj_serializers/json_string_encoder.rb b/lib/oj_serializers/json_string_encoder.rb index c4952cd..ac2be95 100644 --- a/lib/oj_serializers/json_string_encoder.rb +++ b/lib/oj_serializers/json_string_encoder.rb @@ -25,7 +25,7 @@ def encode_to_json(object, root: nil, serializer: nil, each_serializer: nil, **o object end payload = root ? { root => result } : result - defined?(Oj) ? Oj.dump(payload) : JSON.generate(payload) + JSON.generate(payload.as_json) end if OjSerializers::Serializer::DEV_MODE diff --git a/lib/oj_serializers/json_value.rb b/lib/oj_serializers/json_value.rb index 45f1751..dea6156 100644 --- a/lib/oj_serializers/json_value.rb +++ b/lib/oj_serializers/json_value.rb @@ -36,10 +36,6 @@ def to_json(_options = nil) # When oj is not loaded, returns a JSON::Fragment so JSON.generate embeds # the pre-encoded string directly. def as_json(_options = nil) - if !defined?(Oj) && defined?(JSON::Fragment) - JSON::Fragment.new(@json) - else - self - end + defined?(JSON::Fragment) ? JSON::Fragment.new(@json) : self end end diff --git a/lib/oj_serializers/json_writer.rb b/lib/oj_serializers/json_writer.rb deleted file mode 100644 index f5da1b7..0000000 --- a/lib/oj_serializers/json_writer.rb +++ /dev/null @@ -1,108 +0,0 @@ -# frozen_string_literal: true - -require 'json' - -# Public: A pure-Ruby replacement for Oj::StringWriter that builds JSON via -# string concatenation. Implements the same API used by the generated -# `write_to_json` code so the serializer can work without the oj gem. -class OjSerializers::JsonWriter - def initialize(**) - @io = +"" - @stack = [] # tracks [:object/:array, needs_comma] - @after_key = false - end - - # Write the opening `{` of a JSON object. - def push_object - write_comma_if_needed - @after_key = false - @io << "{" - @stack.push([:object, false]) - end - - # Write the opening `[` of a JSON array. - def push_array - write_comma_if_needed - @after_key = false - @io << "[" - @stack.push([:array, false]) - end - - # Write the closing `}` or `]` for the current nesting level. - def pop - type, = @stack.pop - @io << (type == :object ? "}" : "]") - @stack.last[1] = true if @stack.any? - @after_key = false - end - - # Write a value, optionally with a key (for object members). - # - # writer.push_value("hello", "name") => "name":"hello" - # writer.push_value(42) => 42 - def push_value(value, key = nil) - write_comma_if_needed - @after_key = false - if key - @io << "#{JSON.generate(key)}:" - end - @io << json_encode(value) - @stack.last[1] = true if @stack.any? - end - - # Write a key for the next value. Used for associations where the serializer - # will call push_object or push_array next. - # - # writer.push_key("songs") => "songs": - def push_key(key) - write_comma_if_needed - @io << "#{JSON.generate(key)}:" - @after_key = true - end - - # Embed a raw JSON string directly. Used for cached serialized items. - def push_json(json) - write_comma_if_needed - @after_key = false - @io << json.chomp("\n") - @stack.last[1] = true if @stack.any? - end - - # Return the built JSON string. - def to_s - @io - end - - # Compatibility: return the JSON string without a trailing newline. - def to_json(_options = nil) - @io - end - - # Compatibility: return self so it can be used transparently. - def as_json(_options = nil) - self - end - -private - - def write_comma_if_needed - return if @after_key - - if @stack.any? && @stack.last[1] - @io << "," - end - @after_key = false - end - - def json_encode(value) - case value - when String then JSON.generate(value) - when Integer, Float then value.to_s - when true then "true" - when false then "false" - when nil then "null" - when OjSerializers::JsonValue then value.to_s - else JSON.generate(value) - end - end -end diff --git a/lib/oj_serializers/serializer.rb b/lib/oj_serializers/serializer.rb index 6b0762c..53212d0 100644 --- a/lib/oj_serializers/serializer.rb +++ b/lib/oj_serializers/serializer.rb @@ -10,11 +10,6 @@ # Public: Implementation of an "ActiveModelSerializer"-like DSL, but with a # design that allows replacing the internal object, which greatly reduces object # allocation. -# -# Unlike ActiveModelSerializer, which builds a Hash which then gets encoded to -# JSON, this implementation allows to use Oj::StringWriter to write directly to -# JSON, greatly reducing the overhead of allocating and garbage collecting the -# hashes. class OjSerializers::Serializer # Public: Used to validate incorrect memoization during development. Users of # this library might add additional options as needed. @@ -35,8 +30,14 @@ class OjSerializers::Serializer serializer ].to_set - CACHE = (defined?(Rails) && Rails.cache) || - (defined?(ActiveSupport::Cache::MemoryStore) ? ActiveSupport::Cache::MemoryStore.new : OjSerializers::Memo.new) + CACHE = begin + (defined?(Rails) && Rails.cache) || begin + require 'active_support/cache' + ActiveSupport::Cache::MemoryStore.new + end + rescue LoadError + OjSerializers::Memo.new + end # Internal: The environment the app is currently running on. environment = ENV['RACK_ENV'] || ENV['RAILS_ENV'] || 'production' @@ -62,33 +63,6 @@ def _check_instance_variables end end - # Internal: Used internally to write a single object to JSON. - # - # writer - writer used to serialize results - # item - item to serialize results for - # options - list of external options to pass to the serializer (available as `options`) - # - # 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) - writer.push_object - write_to_json(writer, item, options) - writer.pop - end - - # Internal: Used internally to write an array of objects to JSON. - # - # writer - writer used to serialize results - # items - items to serialize results for - # options - list of external options to pass to the serializer (available as `options`) - def write_many(writer, items, options = nil) - writer.push_array - items.each do |item| - write_one(writer, item, options) - end - writer.pop - end - protected # Internal: An internal cache that can be used for temporary memoization. @@ -97,15 +71,6 @@ def memo 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. - # - # This setting is inherited from parent classes. - def default_format(format) - define_singleton_method(:_default_format) { format } - define_serialization_shortcuts - end - # Public: Allows to sort fields by name instead of by definition order, or # pass a Proc to apply a custom order. # @@ -146,18 +111,6 @@ def object_as(name, **) # NOTE: `one` serves as a replacement for `new` in these serializers. private :new - # 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_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_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_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) @@ -173,30 +126,6 @@ def one_if(item, options = nil) one(item, options) if item end - # Public: Serializes the configured attributes for the specified object. - # - # item - the item to serialize - # 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_as_json(item, options = nil) - writer = new_json_writer - write_one(writer, item, options) - writer - end - - # Public: Serializes an array of items using this serializer. - # - # items - Must respond to `each`. - # 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_as_json(items, options = nil) - writer = new_json_writer - write_many(writer, items, options) - writer - end - # Public: Renders the configured attributes for the specified object, # without serializing to JSON. # @@ -246,7 +175,6 @@ 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. @@ -278,62 +206,13 @@ def cached(cache_key_proc = :cache_key.to_proc) 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| - cached_item = CACHE.fetch(item_cache_key(item, cache_key_proc), cache_options) do - writer = new_json_writer - non_cached_write_one(writer, item, options) - writer.to_json - end - external_writer.push_json("#{cached_item}\n") # Oj.dump expects a new line terminator. - end - - # Internal: Redefine `write_many` to use fetch_multi from cache. - define_singleton_method(:write_many) do |external_writer, 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. - cached_items = CACHE.fetch_multi(*items, cache_options) do |item| - writer = new_json_writer - non_cached_write_one(writer, item, options) - writer.to_json - 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 - - # Internal: The writer to use to write to json - def new_json_writer - if defined?(Oj::StringWriter) - Oj::StringWriter.new(mode: :rails) - else - OjSerializers::JsonWriter.new - end + def define_serialization_shortcuts + singleton_class.alias_method :one, :one_as_hash + singleton_class.alias_method :many, :many_as_hash end # Public: Identifiers are always serialized first. @@ -475,32 +354,6 @@ def many?(item) (defined?(Mongoid::Association::Many) && item.is_a?(Mongoid::Association::Many)) end - # 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. - # - # As a result, the performance is the same as writing the most efficient - # code by hand. - def code_to_write_to_json(attributes) - <<~WRITE_TO_JSON - # Public: Writes this serializer content to a provided Oj::StringWriter. - def write_to_json(writer, item, options = nil) - @object = item - @options = options - @memo.clear if defined?(@memo) - #{ attributes.map { |key, options| - code_to_write_conditionally(options) { - if options[:association] - code_to_write_association(key, options) - else - code_to_write_attribute(key, options) - end - } - }.join("\n ") }#{code_to_rescue_no_method if DEV_MODE} - end - WRITE_TO_JSON - end - # 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. @@ -561,72 +414,6 @@ def check_conditional_method(options) include_method_name if method_defined?(include_method_name) 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_write_conditionally(options) - if (include_method_name = check_conditional_method(options)) - "if #{include_method_name};#{yield};end\n" - else - yield - end - end - - # Internal: Returns the code for the association method. - def code_to_write_attribute(key, options) - value_from = options.fetch(:value_from) - - value = case (strategy = options.fetch(:attribute)) - when :serializer - # Obtains the value by calling a method in the serializer. - value_from - when :method - # Obtains the value by calling a method in the object, and writes it. - "@object.#{value_from}" - when :hash - # Writes a Hash value to JSON, works with String or Symbol keys. - "@object[#{value_from.inspect}]" - when :mongoid - # Writes an Mongoid attribute to JSON, this is the fastest strategy. - "@object.attributes['#{value_from}']" - else - raise ArgumentError, "Unknown attribute strategy: #{strategy.inspect}" - end - - "writer.push_value(#{value}, #{key.inspect})" - end - - # Internal: Returns the code for the association method. - def code_to_write_association(key, options) - # Use a serializer method if defined, else call the association in the object. - value_from = options.fetch(:value_from) - value = method_defined?(value_from) ? value_from : "@object.#{value_from}" - serializer_class = options.fetch(:serializer) - - case type = options.fetch(:association) - when :one - <<~WRITE_ONE - if __value = #{value} - writer.push_key('#{key}') - #{serializer_class}.write_one(writer, __value, options) - end - WRITE_ONE - when :many - <<~WRITE_MANY - writer.push_key('#{key}') - #{serializer_class}.write_many(writer, #{value}, options) - WRITE_MANY - when :flat - <<~WRITE_FLAT - #{serializer_class}.write_to_json(writer, #{value}, options) - WRITE_FLAT - else - raise ArgumentError, "Unknown association type: #{type.inspect}" - end - end - # Internal: Returns the code to render an attribute or association # conditionally. # @@ -699,11 +486,10 @@ def instance_key end end - # Internal: Generates write_to_json and render_as_hash methods optimized for + # Internal: Generates the render_as_hash method optimized for # the specified configuration. def prepare_serializer attributes = prepare_attributes - class_eval(code_to_write_to_json(attributes)) class_eval(code_to_render_as_hash(attributes)) end @@ -729,7 +515,7 @@ def prepare_attributes(transform_keys: try(:_transform_keys), sort_by: try(:_sor end end - default_format :hash + define_serialization_shortcuts end unless defined?(Oj::Serializer) diff --git a/lib/oj_serializers/setup.rb b/lib/oj_serializers/setup.rb deleted file mode 100644 index c75938c..0000000 --- a/lib/oj_serializers/setup.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -require 'oj' - -# NOTE: We automatically set the necessary configuration unless it had been -# explicitly set beforehand. -unless Oj.default_options[:use_raw_json] - require 'rails' - Oj.optimize_rails - Oj.default_options = { mode: :rails, use_raw_json: true } -end - -# NOTE: Add an optimization to make it easier to work with a StringWriter -# transparently in different scenarios. -class Oj::StringWriter - alias original_as_json as_json - - # Internal: ActiveSupport can pass an options argument to `as_json` when - # serializing a Hash or Array. - def as_json(_options = nil) - original_as_json - end - - # Internal: We can use `to_s` directly, this is not important but gives a - # slight boost to a few use cases that use it for caching in Memcached. - def to_json(_options = nil) - to_s.delete_suffix("\n") - end -end diff --git a/oj_serializers.gemspec b/oj_serializers.gemspec index 2ca81ce..6201e95 100644 --- a/oj_serializers.gemspec +++ b/oj_serializers.gemspec @@ -22,9 +22,5 @@ Gem::Specification.new do |spec| spec.files = Dir.glob('{lib}/**/*.rb') + %w[README.md CHANGELOG.md] spec.require_paths = ['lib'] - # oj is optional: when available, uses Oj::StringWriter for streaming JSON; - # otherwise falls back to a pure-Ruby JsonWriter + JSON.generate. - # spec.add_dependency 'oj', '>= 3.14.0' - spec.metadata['rubygems_mfa_required'] = 'true' end diff --git a/spec/oj_serializers/caching_spec.rb b/spec/oj_serializers/caching_spec.rb index 08ce73a..f89f3a9 100644 --- a/spec/oj_serializers/caching_spec.rb +++ b/spec/oj_serializers/caching_spec.rb @@ -46,21 +46,4 @@ 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/compat_spec.rb b/spec/oj_serializers/compat_spec.rb index 55901dd..02569c2 100644 --- a/spec/oj_serializers/compat_spec.rb +++ b/spec/oj_serializers/compat_spec.rb @@ -10,13 +10,9 @@ class CompatSerializer < Oj::Serializer has_many :items, serializer: ActiveModelSerializer, unless: -> { options[:skip_collection] } end -class JsonCompatSerializer < CompatSerializer - default_format :json -end - RSpec.describe 'AMS Compat', type: :serializer do def expect_encoded_json(object) - expect(Oj.dump(object).tr("\n", '')) + expect(JSON.generate(object)) end it 'can use ams serializer in associations' do @@ -32,14 +28,5 @@ def expect_encoded_json(object) expect_encoded_json(CompatSerializer.one(object, skip_collection: true)).to eq({ album: attrs, }.to_json) - - expect_encoded_json(JsonCompatSerializer.one(object)).to eq({ - album: attrs, - items: [attrs, attrs], - }.to_json) - - expect_encoded_json(JsonCompatSerializer.one(object, skip_collection: true)).to eq({ - album: attrs, - }.to_json) end end diff --git a/spec/oj_serializers/json_string_encoder_spec.rb b/spec/oj_serializers/json_string_encoder_spec.rb index ba972ae..f174373 100644 --- a/spec/oj_serializers/json_string_encoder_spec.rb +++ b/spec/oj_serializers/json_string_encoder_spec.rb @@ -50,7 +50,7 @@ def expect_incorrect_usage(object, options = {}) expect_encoded_json(complex_array).to eq([{ complex_array: [hash, hash] }].to_json) expect_encoded_json(complex_array, root: :mixed).to eq({ mixed: [{ complex_array: [hash, hash] }] }.to_json) - expect(complex.as_json.to_json).to eq([{ complex: hash }].to_json) + expect(JSON.generate(complex.as_json)).to eq([{ complex: hash }].to_json) expect(OjSerializers::JsonValue.new(json_string).to_s).to eq json_string end end diff --git a/spec/support/controllers/application.rb b/spec/support/controllers/application.rb index 73515a5..5dda3ba 100644 --- a/spec/support/controllers/application.rb +++ b/spec/support/controllers/application.rb @@ -7,8 +7,19 @@ class MusicApplication < Rails::Application def quick_setup - initializers.find { |i| i.name == 'active_model_serializers.action_controller' }.run - initializers.find { |i| i.name == 'oj_serializers.action_controller' }.run + ams_init = initializers.find { |i| i.name == 'active_model_serializers.action_controller' } + oj_init = initializers.find { |i| i.name == 'oj_serializers.action_controller' } + + if ams_init && oj_init + ams_init.run + oj_init.run + else + # If initializers weren't registered (e.g. due to load order), manually + # include the modules needed for the controller tests. + require 'action_controller/serialization' + ApplicationController.include(::ActionController::Serialization) + ApplicationController.include(OjSerializers::ControllerSerialization) + end end end diff --git a/spec/support/non_blank_json_writer.rb b/spec/support/non_blank_json_writer.rb deleted file mode 100644 index 574c112..0000000 --- a/spec/support/non_blank_json_writer.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -# Public: An example on how the writer can be modified to do funky stuff. -class NonBlankJsonWriter < DelegateClass(Oj::StringWriter) - def self.new - super(Oj::StringWriter.new(mode: :rails)) - end - - def push_value(value, key = nil) - super if value.present? - end - - # Internal: Used by Oj::Rails::Encoder because we use the `raw_json` option. - def raw_json(*) - # We need to pass no arguments to `Oj::StringWriter` because it expects 0 arguments - # because its method definition `oj_dump_raw_json` defined in the C classes is defined - # without arguments. Oj gets confused because it checks if the class is `Oj::StringWriter` - # and if it is, then it passes 0 arguments, but when it's not (e.g. `NonBlankJsonWriter`) - # then it passes both. So in this case, we're calling super() to `Oj::StringWriter` with - # two arguments. - # - # https://github.com/ohler55/oj/commit/d0820d2ac1a72584329bc6451d430737a27f99ac#diff-854d0b67397d7006482043d1202c9647R532 - super() - end -end diff --git a/spec/support/serializers/option_serializer.rb b/spec/support/serializers/option_serializer.rb index da543cc..2bd64c1 100644 --- a/spec/support/serializers/option_serializer.rb +++ b/spec/support/serializers/option_serializer.rb @@ -68,22 +68,6 @@ def value end end - def self.write_models(models) - writer = ::Oj::StringWriter.new(mode: :wab) - - writer.push_array - - models.each do |model| - writer.push_object - writer.push_value(model.attributes['name'], 'label') - writer.push_value(model.attributes['_id'], 'value') - writer.pop - end - writer.pop - - writer - end - def self.map_models(models) models.map do |model| { label: model.attributes['name'], value: model.attributes['_id'] } From 67406c748610754ce6c09e1d19a0d9976919217e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 01:17:45 +0000 Subject: [PATCH 5/6] refactor: rename gem from oj_serializers to json_serializers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename directories, files, modules, and class references throughout: - lib/oj_serializers/ β†’ lib/json_serializers/ - OjSerializers β†’ JsonSerializers - Oj::Serializer β†’ JsonSerializer (with backward-compat alias) - Update README to emphasize hash-based architecture and memory efficiency - Update MIGRATION_GUIDE.md and CHANGELOG.md references https://claude.ai/code/session_018LrqGa8M4Cnvt8cZ2489Vn --- .rubocop_todo.yml | 12 +- CHANGELOG.md | 22 +- Gemfile.lock | 4 +- MIGRATION_GUIDE.md | 14 +- README.md | 225 +++++------------- benchmarks/album_serializer_benchmark.rb | 6 +- benchmarks/game_serializer_benchmark.rb | 2 +- benchmarks/generate_charts.rb | 8 +- benchmarks/memory_usage_benchmark.rb | 4 +- benchmarks/model_serializer_benchmark.rb | 2 +- benchmarks/option_serializer_benchmark.rb | 2 +- benchmarks/runner.rb | 6 +- .../my_api/app/serializers/base_serializer.rb | 2 +- examples/my_api/config/initializers/json.rb | 2 +- ...lizers.gemspec => json_serializers.gemspec | 14 +- lib/json_serializers.rb | 5 + .../compat.rb | 6 +- .../controller_serialization.rb | 12 +- .../json_string_encoder.rb | 12 +- .../json_value.rb | 12 +- .../memo.rb | 2 +- .../serializer.rb | 21 +- lib/json_serializers/sugar.rb | 17 ++ .../version.rb | 2 +- lib/oj_serializers.rb | 5 - lib/oj_serializers/sugar.rb | 17 -- spec/benchmark_helper.rb | 2 +- .../associations_spec.rb | 0 .../caching_spec.rb | 2 +- .../compat_spec.rb | 2 +- .../dev_mode_spec.rb | 6 +- .../json_string_encoder_spec.rb | 14 +- .../legacy_mode_spec.rb | 2 +- .../memo_spec.rb | 2 +- .../sort_attributes_spec.rb | 0 .../sugar_spec.rb | 0 .../transform_keys_spec.rb | 0 spec/json_serializers/version_spec.rb | 7 + spec/oj_serializers/version_spec.rb | 7 - spec/spec_helper.rb | 2 +- spec/support/controllers/application.rb | 6 +- spec/support/models/sql.rb | 6 +- .../serializers/active_model_serializer.rb | 2 +- spec/support/serializers/album_serializer.rb | 2 +- spec/support/serializers/blueprints.rb | 2 +- .../serializers/invalid_album_serializer.rb | 2 +- .../support/serializers/legacy_serializers.rb | 2 +- spec/support/serializers/model_serializer.rb | 2 +- spec/support/serializers/option_serializer.rb | 4 +- spec/support/serializers/song_serializer.rb | 2 +- 50 files changed, 201 insertions(+), 311 deletions(-) rename oj_serializers.gemspec => json_serializers.gemspec (63%) create mode 100644 lib/json_serializers.rb rename lib/{oj_serializers => json_serializers}/compat.rb (90%) rename lib/{oj_serializers => json_serializers}/controller_serialization.rb (63%) rename lib/{oj_serializers => json_serializers}/json_string_encoder.rb (80%) rename lib/{oj_serializers => json_serializers}/json_value.rb (63%) rename lib/{oj_serializers => json_serializers}/memo.rb (94%) rename lib/{oj_serializers => json_serializers}/serializer.rb (97%) create mode 100644 lib/json_serializers/sugar.rb rename lib/{oj_serializers => json_serializers}/version.rb (70%) delete mode 100644 lib/oj_serializers.rb delete mode 100644 lib/oj_serializers/sugar.rb rename spec/{oj_serializers => json_serializers}/associations_spec.rb (100%) rename spec/{oj_serializers => json_serializers}/caching_spec.rb (95%) rename spec/{oj_serializers => json_serializers}/compat_spec.rb (95%) rename spec/{oj_serializers => json_serializers}/dev_mode_spec.rb (88%) rename spec/{oj_serializers => json_serializers}/json_string_encoder_spec.rb (90%) rename spec/{oj_serializers => json_serializers}/legacy_mode_spec.rb (97%) rename spec/{oj_serializers => json_serializers}/memo_spec.rb (90%) rename spec/{oj_serializers => json_serializers}/sort_attributes_spec.rb (100%) rename spec/{oj_serializers => json_serializers}/sugar_spec.rb (100%) rename spec/{oj_serializers => json_serializers}/transform_keys_spec.rb (100%) create mode 100644 spec/json_serializers/version_spec.rb delete mode 100644 spec/oj_serializers/version_spec.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index f837b9d..0830495 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -19,7 +19,7 @@ Bundler/OrderedGems: # Include: **/*.gemspec Gemspec/RequiredRubyVersion: Exclude: - - 'oj_serializers.gemspec' + - 'json_serializers.gemspec' # Offense count: 5 # This cop supports safe autocorrection (--autocorrect). @@ -44,21 +44,21 @@ Lint/RedundantDirGlobSort: # SupportedStyles: strict, consistent Lint/SymbolConversion: Exclude: - - 'lib/oj_serializers/serializer.rb' + - 'lib/json_serializers/serializer.rb' # Offense count: 1 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: AutoCorrect, IgnoreEmptyBlocks, AllowUnusedKeywordArguments. Lint/UnusedBlockArgument: Exclude: - - 'lib/oj_serializers/serializer.rb' + - 'lib/json_serializers/serializer.rb' # Offense count: 2 # This cop supports unsafe autocorrection (--autocorrect-all). # Configuration parameters: AllowedReceivers. Lint/UselessDefaultValueArgument: Exclude: - - 'lib/oj_serializers/serializer.rb' + - 'lib/json_serializers/serializer.rb' # Offense count: 5 # This cop supports safe autocorrection (--autocorrect). @@ -72,7 +72,7 @@ Style/StringLiterals: # This cop supports safe autocorrection (--autocorrect). Style/SuperArguments: Exclude: - - 'lib/oj_serializers/serializer.rb' + - 'lib/json_serializers/serializer.rb' # Offense count: 1 # This cop supports unsafe autocorrection (--autocorrect-all). @@ -88,7 +88,7 @@ Style/SymbolProc: # SupportedStylesForMultiline: comma, consistent_comma, no_comma Style/TrailingCommaInArguments: Exclude: - - 'spec/oj_serializers/sort_attributes_spec.rb' + - 'spec/json_serializers/sort_attributes_spec.rb' # Offense count: 17 # This cop supports safe autocorrection (--autocorrect). diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a191eb..b5fa011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,16 @@ -## Oj Serializers 3.0.0 (2026-01-02) +## JSON Serializers 3.0.0 (2026-01-02) ### Features ✨ - [Pass user options to children associations](https://github.com/ElMassimo/oj_serializers/commit/d495f06) -## Oj Serializers 2.1.0 (2026-01-02) +## JSON Serializers 2.1.0 (2026-01-02) ### Fixes 🐞 - [Improve sorting by :definition with more than 10 attributes in the list](https://github.com/ElMassimo/oj_serializers/commit/d58cb81) (#25) -## Oj Serializers 2.0.3 (2023-04-19) +## JSON Serializers 2.0.3 (2023-04-19) ### Features ✨ @@ -20,7 +20,7 @@ - [Allow using `active_model_serializers` in associations](https://github.com/ElMassimo/oj_serializers/commit/501ed4014b564e6f103d2f52d15832fe6706d6a8) -## Oj Serializers 2.0.2 (2023-04-02) +## JSON Serializers 2.0.2 (2023-04-02) ### Features ✨ @@ -30,7 +30,7 @@ - [Error when defining attributes with options](https://github.com/ElMassimo/oj_serializers/commit/680ab47) -## Oj Serializers 2.0.1 (2023-04-02) +## JSON Serializers 2.0.1 (2023-04-02) ### Features ✨ @@ -41,7 +41,7 @@ - [Aliased attributes should be sorted by the output key](https://github.com/ElMassimo/oj_serializers/commit/fc6f4c1) -## [Oj Serializers 2.0.0 (2023-03-27)](https://github.com/ElMassimo/oj_serializers/pull/9) +## [JSON Serializers 2.0.0 (2023-03-27)](https://github.com/ElMassimo/oj_serializers/pull/9) ### Features ✨ @@ -54,20 +54,18 @@ ### 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. +Since returning a `Hash` is more convenient 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) ## +## JSON Serializers 1.0.2 (2023-03-01) ## * [fix: avoid freezing `ALLOWED_INSTANCE_VARIABLES`](https://github.com/ElMassimo/oj_serializers/commit/ade0302) -## Oj Serializers 1.0.1 (2023-03-01) ## +## JSON Serializers 1.0.1 (2023-03-01) ## * [fix: avoid caching instances of reloaded classes in development](https://github.com/ElMassimo/oj_serializers/commit/0bd928d64d159926acf6b4d57e3f08b12f6931ce) -## Oj Serializers 1.0.0 (2020-11-05) ## +## JSON Serializers 1.0.0 (2020-11-05) ## * Initial Release. diff --git a/Gemfile.lock b/Gemfile.lock index 0652fb8..d07dc29 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - oj_serializers (3.0.0) + json_serializers (3.0.0) GEM remote: https://rubygems.org/ @@ -295,9 +295,9 @@ DEPENDENCIES benchmark-memory blueprinter (~> 0.8) json + json_serializers! memory_profiler mongoid - oj_serializers! panko_serializer pry-byebug (~> 3.9) rails diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md index b93440d..a35d200 100644 --- a/MIGRATION_GUIDE.md +++ b/MIGRATION_GUIDE.md @@ -5,17 +5,15 @@ [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/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/ -The DSL of `oj_serializers` is meant to be similar to the one provided by `active_model_serializers` to make the migration process simple, +The DSL of `json_serializers` is meant to be similar to the one provided by `active_model_serializers` to make the migration process simple, though the goal is not to be a drop-in replacement. ## Rendering πŸ›  @@ -24,7 +22,7 @@ To use the same format in controllers, using the `root`, `serializer`, `each_ser ```ruby # config/initializers/json.rb -require 'oj_serializers/compat' +require 'json_serializers/compat' ``` Otherwise, use `one` and `many` to serialize objects or enumerables: @@ -80,7 +78,7 @@ end # becomes -class AlbumSerializer < Oj::Serializer +class AlbumSerializer < JsonSerializer ams_attributes :name, :release # The serializer class must be explicitly provided. @@ -102,7 +100,7 @@ Once your serializer is working as expected, you can further refactor it to be m Being explicit about where the attributes are coming from makes the serializers easier to understand and more maintainable. ```ruby -class AlbumSerializer < Oj::Serializer +class AlbumSerializer < JsonSerializer attributes :name has_many :songs, serializer: SongSerializer @@ -140,7 +138,7 @@ In case you need to access path helpers in your serializers, you can use the following: ```ruby -class BaseJsonSerializer < Oj::Serializer +class BaseJsonSerializer < JsonSerializer include Rails.application.routes.url_helpers def default_url_options @@ -165,7 +163,7 @@ class ApplicationController < ActionController::Base before_action { Thread.current[:current_controller] = self } end -class BaseJsonSerializer < Oj::Serializer +class BaseJsonSerializer < JsonSerializer def scope @scope ||= Thread.current[:current_controller] end diff --git a/README.md b/README.md index e3177a5..2df1475 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,16 @@

-Oj Serializers +JSON Serializers

Build Status Maintainability Test Coverage -Gem Version +Gem Version License

-Faster JSON serializers for Ruby, built on top of the powerful [`oj`][oj] library. +Fast, memory-efficient JSON serializers for Ruby and Rails. -[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 @@ -19,12 +18,11 @@ Faster JSON serializers for Ruby, built on top of the powerful [`oj`][oj] librar [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/main/benchmarks/document_benchmark.rb -[sugar]: https://github.com/ElMassimo/oj_serializers/blob/main/lib/oj_serializers/sugar.rb#L14 +[sugar]: https://github.com/ElMassimo/oj_serializers/blob/main/lib/json_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- [associations]: https://github.com/ElMassimo/oj_serializers#associations- [compose]: https://github.com/ElMassimo/oj_serializers#composing-serializers- -[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/ @@ -33,16 +31,16 @@ Faster JSON serializers for Ruby, built on top of the powerful [`oj`][oj] librar [types_from_serializers]: https://github.com/ElMassimo/types_from_serializers [inheritance]: https://github.com/ElMassimo/types_from_serializers/blob/main/playground/vanilla/app/serializers/song_with_videos_serializer.rb#L1 -## Why? πŸ€” +## Why? [`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]. +`JsonSerializer` provides a similar API, with [better performance][benchmarks]. Learn more about [how this library achieves its performance][design]. -## Features ⚑️ +## Features - Intuitive declaration syntax, supporting mixins and inheritance - Reduced [memory allocation][benchmarks] and [improved performance][benchmarks] @@ -51,25 +49,25 @@ Learn more about [how this library achieves its performance][design]. - Useful development checks to avoid typos and mistakes - [Migrate easily from Active Model Serializers][migration guide] -## Installation πŸ’Ώ +## Installation Add this line to your application's Gemfile: ```ruby -gem 'oj_serializers' +gem 'json_serializers' ``` And then run: $ bundle install -## Usage πŸš€ +## Usage -You can define a serializer by subclassing `Oj::Serializer`, and specify which +You can define a serializer by subclassing `JsonSerializer`, and specify which attributes should be serialized. ```ruby -class AlbumSerializer < Oj::Serializer +class AlbumSerializer < JsonSerializer attributes :name, :genres attribute :release do @@ -162,13 +160,13 @@ end Active Model Serializers style ```ruby -require "oj_serializers/sugar" # In an initializer +require "json_serializers/sugar" # In an initializer class AlbumsController < ApplicationController def show render json: album, serializer: AlbumSerializer end - + def index render json: albums, root: :albums, each_serializer: AlbumSerializer end @@ -176,7 +174,7 @@ end ``` -## Rendering πŸ–¨ +## Rendering Use `one` to serialize objects, and `many` to serialize enumerables: @@ -200,12 +198,12 @@ render json: { } ``` -## Attributes DSL πŸͺ„ +## Attributes DSL Specify which attributes should be rendered by calling a method in the object to serialize. ```ruby -class PlayerSerializer < Oj::Serializer +class PlayerSerializer < JsonSerializer attributes :first_name, :last_name, :full_name end ``` @@ -213,7 +211,7 @@ end You can serialize custom values by specifying that a method is an `attribute`: ```ruby -class PlayerSerializer < Oj::Serializer +class PlayerSerializer < JsonSerializer attribute :name do "#{player.first_name} #{player.last_name}" end @@ -234,14 +232,14 @@ end > You can customize this by using [`object_as`](#using-a-different-alias-for-the-internal-object). -### Associations πŸ”— +### Associations Use `has_one` to serialize individual objects, and `has_many` to serialize a collection. You must specificy which serializer to use with the `serializer` option. ```ruby -class SongSerializer < Oj::Serializer +class SongSerializer < JsonSerializer has_one :album, serializer: AlbumSerializer has_many :composers, serializer: ComposerSerializer end @@ -250,7 +248,7 @@ end Specify a different value for the association by providing a block: ```ruby -class SongSerializer < Oj::Serializer +class SongSerializer < JsonSerializer has_one :album, serializer: AlbumSerializer do Album.find_by(song_ids: song.id) end @@ -260,20 +258,20 @@ end In case you need to pass options, you can call the serializer manually: ```ruby -class SongSerializer < Oj::Serializer +class SongSerializer < JsonSerializer attribute :album do AlbumSerializer.one(song.album, for_song: song) end end ``` -### Aliasing or renaming attributes ↔️ +### Aliasing or renaming attributes You can pass `as` when defining an attribute or association to serialize it using a different key: ```ruby -class SongSerializer < Oj::Serializer +class SongSerializer < JsonSerializer has_one :album, as: :first_release, serializer: AlbumSerializer attributes title: {as: :name} @@ -283,12 +281,12 @@ class SongSerializer < Oj::Serializer end ``` -### Conditional attributes ❔ +### Conditional attributes You can render attributes and associations conditionally by using `:if`. ```ruby -class PlayerSerializer < Oj::Serializer +class PlayerSerializer < JsonSerializer attributes :first_name, :last_name, if: -> { player.display_name? } has_one :album, serializer: AlbumSerializer, if: -> { player.album } @@ -297,7 +295,7 @@ end This is useful in cases where you don't want to `null` values to be in the response. -## Advanced Usage πŸ§™β€β™‚οΈ +## Advanced Usage ### Using a different alias for the internal object @@ -306,7 +304,7 @@ 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 +class DiscographySerializer < JsonSerializer object_as :artist # Now we can use `artist` instead of `object` or `discography`. @@ -323,7 +321,7 @@ The `identifier` method allows you to only include an identifier if the record or document has been persisted. ```ruby -class AlbumSerializer < Oj::Serializer +class AlbumSerializer < JsonSerializer identifier # or if it's a different field @@ -334,7 +332,7 @@ end Additionally, identifier fields are always rendered first, even when sorting fields alphabetically. -### Transforming attribute keys πŸ— +### 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. @@ -345,7 +343,7 @@ where properties are traditionally named using camel case. Use `transform_keys` to handle that conversion. ```ruby -class BaseSerializer < Oj::Serializer +class BaseSerializer < JsonSerializer transform_keys :camelize # shortcut for @@ -355,7 +353,7 @@ end This has no performance impact, as keys will be transformed at load time. -### Sorting attributes πŸ“Ά +### Sorting attributes By default attributes are rendered in the order they are defined. @@ -363,20 +361,20 @@ If you would like to sort attributes alphabetically, you can specify it at a serializer level: ```ruby -class BaseSerializer < Oj::Serializer +class BaseSerializer < JsonSerializer sort_attributes_by :name # or a Proc end ``` This has no performance impact, as attributes will be sorted at load time. -### Path helpers πŸ›£ +### Path helpers In case you need to access path helpers in your serializers, you can use the following: ```ruby -class BaseSerializer < Oj::Serializer +class BaseSerializer < JsonSerializer include Rails.application.routes.url_helpers def default_url_options @@ -389,7 +387,7 @@ 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`. -### Generating TypeScript automatically πŸ€– +### Generating TypeScript automatically It's easy for the backend and the frontend to become out of sync. Traditionally, preventing bugs requires writing extensive integration tests. @@ -399,7 +397,7 @@ It's easy for the backend and the frontend to become out of sync. Traditionally, As a result, it's posible to easily detect mismatches between the backend and the frontend, as well as make the fields more discoverable and provide great autocompletion in the frontend, without having to manually write the types. -### Composing serializers 🧱 +### Composing serializers There are three options to [compose serializers](https://github.com/ElMassimo/oj_serializers/discussions/10#discussioncomment-5523921): [inheritance], mixins, and `flat_one`. @@ -444,7 +442,7 @@ sometimes it's convenient to store intermediate calculations. Use `memo` for memoization and storing temporary information. ```ruby -class DownloadSerializer < Oj::Serializer +class DownloadSerializer < JsonSerializer attributes :filename, :size attribute @@ -462,12 +460,12 @@ private end ``` -### `hash_attributes` πŸš€ +### `hash_attributes` Very convenient when serializing Hash-like structures, this strategy uses the `[]` operator. ```ruby -class PersonSerializer < Oj::Serializer +class PersonSerializer < JsonSerializer hash_attributes 'first_name', :last_name end @@ -475,7 +473,7 @@ PersonSerializer.one('first_name' => 'Mary', :middle_name => 'Jane', :last_name # {first_name: "Mary", last_name: "Watson"} ``` -### `mongo_attributes` πŸš€ +### `mongo_attributes` Reads data directly from `attributes` in a [Mongoid] document. @@ -485,12 +483,12 @@ 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 +class AlbumSerializer < JsonSerializer mongo_attributes :id, :name end ``` -### Caching πŸ“¦ +### Caching Usually rendering is so fast that __turning caching on can be slower__. @@ -521,122 +519,19 @@ 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: +## Design -```ruby -class BaseSerializer < Oj::Serializer - default_format :json -end -``` +Unlike `ActiveModel::Serializer`, which allocates a new serializer instance for +every object being serialized, this library reuses a single instance per +serializer class, greatly reducing memory allocation and GC pressure. -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. - -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]. - -
- 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" - ] - } - ] -} -``` -
- -## Design πŸ“ - -Unlike `ActiveModel::Serializer`, which builds a Hash that then gets encoded to -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. +Serialization builds a plain Ruby Hash via code generation, which is then encoded +to JSON using Ruby's built-in `JSON.generate`. This approach keeps the +implementation simple and portable β€” no C extensions are required. 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 πŸ˜ƒ +Please open a [Discussion] if you need help. ### Comparison with other libraries @@ -647,16 +542,14 @@ evaluate serializers in the context of a `class` instead of an `instance` of a c 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. +[`panko-serializer`][panko] uses C extensions for performance, 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 doesn't support Mongoid. -Follow [this discussion][raw_json] to find out more about [the `raw_json` extensions][raw_json] that made this high level of interoperability possible. +`JsonSerializer` 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. -### Benchmarks πŸ“Š +### Benchmarks This library includes some [benchmarks] to compare performance with similar libraries. @@ -669,12 +562,12 @@ 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 πŸ“ +## Formatting Even though most of the examples above use a single-line style to be succint, I highly recommend writing one attribute per line, sorting them alphabetically (most editors can do it for you), and [always using a trailing comma][trailing_commas]. ```ruby -class AlbumSerializer < Oj::Serializer +class AlbumSerializer < JsonSerializer attributes( :genres, :name, @@ -685,14 +578,14 @@ end It will make things clearer, minimize the amount of git conflicts, and keep the history a lot cleaner and more meaningful when using `git blame`. -## Special Thanks πŸ™ +## Special Thanks -This library wouldn't be possible without the wonderful and performant [`oj`](https://github.com/ohler55/oj) library. Thanks [Peter](https://github.com/ohler55)! πŸ˜ƒ +This library was originally built on top of the [`oj`](https://github.com/ohler55/oj) gem. Thanks [Peter](https://github.com/ohler55)! Also, thanks to the libraries that inspired this one: - [`active_model_serializers`][ams]: For the DSL -- [`panko-serializer`][panko]: For validating that using `Oj::StringWriter` was indeed fast +- [`panko-serializer`][panko]: For early performance validation ## License diff --git a/benchmarks/album_serializer_benchmark.rb b/benchmarks/album_serializer_benchmark.rb index 1f0c93a..553a676 100644 --- a/benchmarks/album_serializer_benchmark.rb +++ b/benchmarks/album_serializer_benchmark.rb @@ -17,7 +17,7 @@ album = Album.abraxas Benchmark.ips do |x| x.config(time: 5, warmup: 2) - x.report('oj_serializers') do + x.report('json_serializers') do JSON.generate(AlbumSerializer.one(album)) end x.report('panko') do @@ -40,7 +40,7 @@ albums = 100.times.map { Album.abraxas } Benchmark.ips do |x| x.config(time: 5, warmup: 2) - x.report('oj_serializers') do + x.report('json_serializers') do JSON.generate(AlbumSerializer.many(albums)) end x.report('panko') do @@ -63,7 +63,7 @@ albums = 1000.times.map { Album.abraxas } Benchmark.ips do |x| x.config(time: 5, warmup: 2) - x.report('oj_serializers') do + x.report('json_serializers') do JSON.generate(AlbumSerializer.many(albums)) end x.report('panko') do diff --git a/benchmarks/game_serializer_benchmark.rb b/benchmarks/game_serializer_benchmark.rb index bf8504c..a4729e8 100644 --- a/benchmarks/game_serializer_benchmark.rb +++ b/benchmarks/game_serializer_benchmark.rb @@ -47,7 +47,7 @@ def scores Benchmark.ips do |x| x.config(time: 5, warmup: 2) - x.report('oj_serializers') do + x.report('json_serializers') do JSON.generate(GameSerializer.one(game)) end x.report('panko') do diff --git a/benchmarks/generate_charts.rb b/benchmarks/generate_charts.rb index e181d55..b1f43b6 100644 --- a/benchmarks/generate_charts.rb +++ b/benchmarks/generate_charts.rb @@ -127,7 +127,7 @@ # Build markdown summary md_lines = [] -md_lines << "# oj_serializers: Oj vs Ruby JSON Benchmark Results" +md_lines << "# json_serializers: Oj vs Ruby JSON Benchmark Results" md_lines << "" md_lines << "| Scenario | " + configs.map { |c| "#{c[:backend]}#{c[:yjit] ? ' (YJIT)' : ''}" @@ -167,7 +167,7 @@ def format_ips(ips) - oj_serializers: Oj vs Ruby JSON Benchmark + json_serializers: Oj vs Ruby JSON Benchmark -

oj_serializers: Oj vs Ruby JSON Benchmark

-

Comparing Oj C extension with Ruby's built-in JSON gem for oj_serializers

+

json_serializers: Oj vs Ruby JSON Benchmark

+

Comparing Oj C extension with Ruby's built-in JSON gem for json_serializers