Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,42 @@ jobs:

- name: Run specs
run: bundle exec rake spec

# The Rails-facing specs against the oldest supported ActiveSupport
# lines (the main matrix already exercises the newest release): the
# drop-in's quirks_mode path and the encoder seam differ across them.
rails-compat:
name: Rails compat (activesupport ${{ matrix.activesupport }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- { activesupport: "~> 7.1.0", ruby: "3.3" }
- { activesupport: "~> 7.2.0", ruby: "3.4" }
- { activesupport: "~> 8.0.0", ruby: "3.4" }
- { activesupport: "~> 8.1.0", ruby: "4.0" }
env:
NOSJ_ACTIVESUPPORT_VERSION: ${{ matrix.activesupport }}

steps:
- uses: actions/checkout@v6

- name: Set up Ruby & Rust
uses: oxidize-rb/actions/setup-ruby-and-rust@v1
with:
ruby-version: ${{ matrix.ruby }}
cargo-cache: true

# The committed lockfile resolves the newest ActiveSupport; the
# pin needs a fresh resolution.
- name: Bundle with the pinned ActiveSupport
run: |
rm Gemfile.lock
bundle install

- name: Compile native extension
run: bundle exec rake compile

- name: Run the Rails-facing specs
run: bundle exec rspec spec/rails_spec.rb spec/rails_integration_spec.rb spec/json_dropin_spec.rb
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,23 @@
## [Unreleased]

- Rails mode: `require "nosj/rails"` accelerates a Rails application
in both directions. It installs a nosj-backed ActiveSupport JSON
encoder, so `obj.to_json`, `render json:`, and `ActiveSupport::JSON.encode` walk the object tree natively—values recurse through `as_json` exactly
like ActiveSupport's own encoder. It also loads the `nosj/json` drop-in, so
`ActiveSupport::JSON.decode` and JSON request-body parsing take the
fast path (including on Rails 7.x, whose `quirks_mode` option the
drop-in now accepts; the drop-in also accepts valid-UTF-8 BINARY
strings now, which is what Rack delivers request bodies as). The
HTML-safety escaping is fused into the SIMD string-emission kernels,
so escaped output costs the same single pass as unescaped. Measured
against stock ActiveSupport encoding: ×1.7 on small documents up to
×5.2 on large trees and ×14 on HTML-heavy content
(`rake bench:rails`). In a Rails Gemfile:
`gem "nosj", require: "nosj/rails"`.
- `JSON::Fragment` values now splice their pre-rendered JSON
everywhere the `json` gem does: in default mode, under `strict:
true`, and through the Rails encoder.

## [0.2.0] - 2026-07-16

- File APIs. `NOSJ.load_file(path, opts)` parses a file directly
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ end

group :test do
gem "rspec", "~> 3.0"
# Pure Ruby; the adapter spec exercises NOSJ::MultiJsonAdapter.

gem "multi_json"

# Pinnable for CI's rails-compat matrix (e.g. "~> 7.1.0"); unpinned,
# the newest release is what the main matrix exercises.
rails_pin = ENV.fetch("NOSJ_ACTIVESUPPORT_VERSION", nil)
gem "activesupport", *[rails_pin].compact
gem "actionpack", *[rails_pin].compact
end

group :development do
Expand Down
86 changes: 86 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,80 @@ PATH
GEM
remote: https://rubygems.org/
specs:
actionpack (8.1.3)
actionview (= 8.1.3)
activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actionview (8.1.3)
activesupport (= 8.1.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activesupport (8.1.3)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
ast (2.4.3)
base64 (0.3.0)
benchmark-ips (2.15.1)
benchmark-memory (0.2.0)
memory_profiler (~> 1)
bigdecimal (4.1.2)
builder (3.3.0)
concurrent-ruby (1.3.7)
connection_pool (3.0.2)
crass (1.0.7)
diff-lcs (1.6.2)
drb (2.2.3)
erubi (1.13.1)
fast_jsonparser (0.6.0)
i18n (1.15.2)
concurrent-ruby (~> 1.0)
json (2.20.0)
language_server-protocol (3.17.0.6)
lefthook (2.1.10)
lint_roller (1.1.0)
logger (1.7.0)
loofah (2.25.2)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
memory_profiler (1.1.0)
mini_portile2 (2.8.9)
minitest (6.0.6)
drb (~> 2.0)
prism (~> 1.5)
multi_json (1.21.1)
nokogiri (1.19.4)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x64-mingw-ucrt)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4)
oj (3.17.3)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
Expand All @@ -31,6 +91,19 @@ GEM
racc
prism (1.9.0)
racc (1.8.1)
rack (3.2.6)
rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.7.1)
loofah (~> 2.25, >= 2.25.2)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rainbow (3.1.1)
rake (13.4.2)
rake-compiler (1.3.1)
Expand Down Expand Up @@ -76,6 +149,7 @@ GEM
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (1.13.0)
securerandom (0.4.1)
standard (1.55.0)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.0)
Expand All @@ -89,17 +163,29 @@ GEM
lint_roller (~> 1.1)
rubocop-performance (~> 1.26.0)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
yajl-ruby (1.4.3)
yard (0.9.45)

PLATFORMS
aarch64-linux
aarch64-linux-musl
arm64-darwin-24
arm64-darwin-25
ruby
x64-mingw-ucrt
x86_64-linux
x86_64-linux-musl

DEPENDENCIES
actionpack
activesupport
benchmark-ips
benchmark-memory
fast_jsonparser
Expand Down
59 changes: 51 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ faster than Yajl—[see Benchmarks](#benchmarks).
- It has **lazy documents**: `NOSJ.lazy` wraps a document and parses a value only when you touch it—repeated access costs nanoseconds, and everything you never read is never parsed.
- It has a **partial parsing mode**: JSON Pointer lookups that pull single values out of big documents in microseconds, skipping everything else.
- It has **file APIs**: parse, generate, dig, and lazy-wrap files directly—no throwaway file-sized Ruby String, and the partial modes memory-map the file so unread pages never even leave the disk.
- It accelerates a **Rails** application in both encoding and decoding.
- It comes **precompiled** (platform gems built with per-platform optimizations,
nothing to compile on install).
- Otherwise, same API and option names as gem json.
Expand Down Expand Up @@ -50,6 +51,8 @@ NOSJ.generate({"a" => [1, true]}) #=> '{"a":[1,true]}'

That's it—if you know the `json` gem, you already know `nosj`.

### gem json compatibility

Want the speedup without touching your code? One line reroutes
`JSON.parse`, `JSON.generate`, `JSON.pretty_generate`, and `JSON.dump`
through nosj:
Expand All @@ -65,14 +68,33 @@ Gemfile you can do this:
gem "nosj", require: "nosj/json"
```

Options nosj supports take the fast path; anything exotic
(`create_additions`, `object_class`, `JSON::State`, procs, IO
arguments) falls back to the original implementation, so `JSON.load`,
`JSON.parse!`, and `JSON.load_file` keep their exact behavior.
Exceptions re-raise as the JSON classes, so your rescue clauses keep
working. Measured through the patch: parse 1.11×, generate 1.05× over
the original gem. (A MultiJson adapter ships too:
`require "nosj/multi_json"`, then `MultiJson.use NOSJ::MultiJsonAdapter`.)
A MultiJson adapter ships too:

```ruby
require "nosj/multi_json"
```

And then `MultiJson.use NOSJ::MultiJsonAdapter`.

### Ruby on Rails

In a Rails app, use this for "Rails mode":

```ruby
gem "nosj", require: "nosj/rails"
```

That installs a nosj-backed ActiveSupport JSON encoder, so `obj.to_json`, `render json:`, and `ActiveSupport::JSON.encode` walk the object tree natively:
values that aren't JSON-native recurse through `as_json` exactly like
ActiveSupport's own encoder, non-finite floats encode as `null`, and
HTML-safety escaping (`escape_html_entities_in_json`) behaves
identically—verified differentially against ActiveSupport's encoder.
It loads the drop-in too, so `ActiveSupport::JSON.decode` and JSON
request-body parsing ride the fast path.

Measured against ActiveSupport's own encoder: ×1.9 on small documents
up to ×5.2 on large trees and ×14 on HTML-heavy content—see
[Benchmarks → Rails mode](#rails-mode).

## What's in the box

Expand Down Expand Up @@ -209,6 +231,27 @@ Silicon dev box, Ruby 4.0.6 + YJIT, PGO build, 2026-07-16):
too much for an honest multiplier; what it saves is the intermediate
file-sized Ruby String.

### Rails mode

`ActiveSupport::JSON.encode` with the nosj encoder installed, against
stock ActiveSupport (Apple Silicon dev box, Ruby 4.0.6 + YJIT,
activesupport 8.1, medians of 5 interleaved per-process rounds,
outputs verified byte-identical first, 2026-07-17;
`rake bench:rails`):

| workload | nosj (i/s) | vs ActiveSupport |
|---|---:|---|
| twitter tree (570 KB) | 4.5k | ×5.2 |
| 100-record index (with timestamps) | 33.7k | ×1.7 |
| HTML-heavy user content | 177.7k | ×14.2 |
| Time/Date/BigDecimal hash | 1.1M | ×3.0 |
| small API hash | 4.2M | ×1.9 |
| small hash `to_json` | 4.1M | ×1.9 |

The HTML-safety escaping that dominates stock encodes of
user-generated content is fused into the SIMD string-emission kernels
here: escaped output costs the same single pass as unescaped.

Reproduce with `rake bench` (the parity-gated comparison, after a PGO retrain—the shipping configuration) or `rake bench:ips` (the multi-gem shoot-out).

## Switching from the json gem
Expand Down
5 changes: 5 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ namespace :bench do
task :ips, [:file] => :compile do |_t, args|
ruby(*["benchmark/benchmark.rb", args[:file], *args.extras].compact)
end

desc "Rails-mode encoder shoot-out (stock/Oj/nosj) on benchmark-ips"
task rails: :compile do
ruby("benchmark/rails_benchmark.rb")
end
end

def bench_sweep(args)
Expand Down
Loading
Loading