multi_json 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,49 +1,86 @@
1
1
  module MultiJson
2
+ # Mixin providing configurable load/dump options
3
+ #
4
+ # Supports static hashes or dynamic callables (procs/lambdas).
5
+ # Extended by both MultiJson (global options) and Adapter classes.
6
+ #
7
+ # @api private
2
8
  module Options
9
+ EMPTY_OPTIONS = {}.freeze
10
+ private_constant :EMPTY_OPTIONS
11
+
12
+ # Set options for load operations
13
+ #
14
+ # @api private
15
+ # @param options [Hash, Proc] options hash or callable
16
+ # @return [Hash, Proc] the options
3
17
  def load_options=(options)
4
18
  OptionsCache.reset
5
19
  @load_options = options
6
20
  end
7
21
 
22
+ # Set options for dump operations
23
+ #
24
+ # @api private
25
+ # @param options [Hash, Proc] options hash or callable
26
+ # @return [Hash, Proc] the options
8
27
  def dump_options=(options)
9
28
  OptionsCache.reset
10
29
  @dump_options = options
11
30
  end
12
31
 
13
- def load_options(*args)
14
- (defined?(@load_options) && get_options(@load_options, *args)) || default_load_options
32
+ # Get options for load operations
33
+ #
34
+ # @api private
35
+ # @return [Hash] resolved options hash
36
+ def load_options(...)
37
+ resolve_options(@load_options, ...) || default_load_options
15
38
  end
16
39
 
17
- def dump_options(*args)
18
- (defined?(@dump_options) && get_options(@dump_options, *args)) || default_dump_options
40
+ # Get options for dump operations
41
+ #
42
+ # @api private
43
+ # @return [Hash] resolved options hash
44
+ def dump_options(...)
45
+ resolve_options(@dump_options, ...) || default_dump_options
19
46
  end
20
47
 
48
+ # Get default load options
49
+ #
50
+ # @api private
51
+ # @return [Hash] frozen empty hash
21
52
  def default_load_options
22
- @default_load_options ||= {}.freeze
53
+ @default_load_options ||= EMPTY_OPTIONS
23
54
  end
24
55
 
56
+ # Get default dump options
57
+ #
58
+ # @api private
59
+ # @return [Hash] frozen empty hash
25
60
  def default_dump_options
26
- @default_dump_options ||= {}.freeze
61
+ @default_dump_options ||= EMPTY_OPTIONS
27
62
  end
28
63
 
29
64
  private
30
65
 
31
- def get_options(options, *args)
32
- return handle_callable_options(options, *args) if options_callable?(options)
33
-
34
- handle_hashable_options(options)
35
- end
36
-
37
- def options_callable?(options)
38
- options.respond_to?(:call)
39
- end
66
+ # Resolves options from a hash or callable
67
+ #
68
+ # @api private
69
+ # @param options [Hash, Proc, nil] options configuration
70
+ # @return [Hash, nil] resolved options hash
71
+ def resolve_options(options, ...)
72
+ return invoke_callable(options, ...) if options.respond_to?(:call)
40
73
 
41
- def handle_callable_options(options, *args)
42
- options.arity.zero? ? options.call : options.call(*args)
74
+ options.to_hash if options.respond_to?(:to_hash)
43
75
  end
44
76
 
45
- def handle_hashable_options(options)
46
- options.respond_to?(:to_hash) ? options.to_hash : nil
77
+ # Invokes a callable options provider
78
+ #
79
+ # @api private
80
+ # @param callable [Proc] options provider
81
+ # @return [Hash] options returned by the callable
82
+ def invoke_callable(callable, ...)
83
+ callable.arity.zero? ? callable.call : callable.call(...)
47
84
  end
48
85
  end
49
86
  end
@@ -1,47 +1,92 @@
1
1
  module MultiJson
2
+ # Thread-safe LRU-like cache for merged options hashes
3
+ #
4
+ # Caches are separated for load and dump operations. Each cache is
5
+ # bounded to prevent unbounded memory growth when options are
6
+ # generated dynamically.
7
+ #
8
+ # @api private
2
9
  module OptionsCache
10
+ # Maximum entries before oldest entry is evicted
11
+ MAX_CACHE_SIZE = 1000
12
+
13
+ # Thread-safe cache store using double-checked locking pattern
14
+ #
15
+ # @api private
3
16
  class Store
4
- # Normally MultiJson is used with a few option sets for both dump/load
5
- # methods. When options are generated dynamically though, every call would
6
- # cause a cache miss and the cache would grow indefinitely. To prevent
7
- # this, we just reset the cache every time the number of keys outgrows
8
- # 1000.
9
- MAX_CACHE_SIZE = 1000
10
- private_constant :MAX_CACHE_SIZE
17
+ # Sentinel value to detect cache misses (unique object identity)
18
+ NOT_FOUND = Object.new
11
19
 
20
+ # Create a new cache store
21
+ #
22
+ # @api private
23
+ # @return [Store] new store instance
12
24
  def initialize
13
25
  @cache = {}
14
26
  @mutex = Mutex.new
15
27
  end
16
28
 
29
+ # Clear all cached entries
30
+ #
31
+ # @api private
32
+ # @return [void]
17
33
  def reset
18
- @mutex.synchronize do
19
- @cache = {}
20
- end
34
+ @mutex.synchronize { @cache.clear }
21
35
  end
22
36
 
23
- def fetch(key)
37
+ # Fetch a value from cache or compute it
38
+ #
39
+ # @api private
40
+ # @param key [Object] cache key
41
+ # @param default [Object] default value if key not found
42
+ # @yield block to compute value if not cached
43
+ # @return [Object] cached or computed value
44
+ def fetch(key, default = nil)
45
+ # Fast path: check cache without lock (safe for reads)
46
+ value = @cache.fetch(key, NOT_FOUND)
47
+ return value unless value.equal?(NOT_FOUND)
48
+
49
+ # Slow path: acquire lock and compute value
24
50
  @mutex.synchronize do
25
- return @cache[key] if @cache.key?(key)
51
+ @cache.fetch(key) { block_given? ? store(key, yield) : default }
26
52
  end
53
+ end
27
54
 
28
- value = yield
55
+ private
29
56
 
30
- @mutex.synchronize do
31
- if @cache.key?(key)
32
- # We ran into a race condition, keep the existing value
33
- @cache[key]
34
- else
35
- @cache.clear if @cache.size >= MAX_CACHE_SIZE
36
- @cache[key] = value
37
- end
57
+ # Stores a value in the cache with LRU eviction
58
+ #
59
+ # @api private
60
+ # @param key [Object] cache key
61
+ # @param value [Object] value to store
62
+ # @return [Object] the stored value
63
+ def store(key, value)
64
+ # Double-check in case another thread computed while we waited
65
+ @cache.fetch(key) do
66
+ # Evict oldest entry if at capacity (Hash maintains insertion order)
67
+ @cache.shift if @cache.size >= MAX_CACHE_SIZE
68
+ @cache[key] = value
38
69
  end
39
70
  end
40
71
  end
41
72
 
42
73
  class << self
43
- attr_reader :dump, :load
74
+ # Get the dump options cache
75
+ #
76
+ # @api private
77
+ # @return [Store] dump cache store
78
+ attr_reader :dump
79
+
80
+ # Get the load options cache
81
+ #
82
+ # @api private
83
+ # @return [Store] load cache store
84
+ attr_reader :load
44
85
 
86
+ # Reset both caches
87
+ #
88
+ # @api private
89
+ # @return [void]
45
90
  def reset
46
91
  @dump = Store.new
47
92
  @load = Store.new
@@ -1,17 +1,46 @@
1
1
  module MultiJson
2
+ # Raised when JSON parsing fails
3
+ #
4
+ # Wraps the underlying adapter's parse error with the original input data.
5
+ #
6
+ # @api public
2
7
  class ParseError < StandardError
8
+ # The input string that failed to parse
9
+ #
10
+ # @api public
11
+ # @return [String, nil] the original input data
12
+ # @example
13
+ # error.data #=> "{invalid json}"
3
14
  attr_reader :data
4
15
 
16
+ # Create a new ParseError
17
+ #
18
+ # @api public
19
+ # @param message [String, nil] error message
20
+ # @param data [String, nil] the input that failed to parse
21
+ # @param cause [Exception, nil] the original exception
22
+ # @return [ParseError] new error instance
23
+ # @example
24
+ # ParseError.new("unexpected token", data: "{invalid}", cause: err)
5
25
  def initialize(message = nil, data: nil, cause: nil)
6
26
  super(message)
7
27
  @data = data
8
28
  set_backtrace(cause.backtrace) if cause
9
29
  end
10
30
 
31
+ # Build a ParseError from an original exception
32
+ #
33
+ # @api public
34
+ # @param original_exception [Exception] the adapter's parse error
35
+ # @param data [String] the input that failed to parse
36
+ # @return [ParseError] new error with formatted message
37
+ # @example
38
+ # ParseError.build(JSON::ParserError.new("..."), "{bad json}")
11
39
  def self.build(original_exception, data)
12
40
  new(original_exception.message, data: data, cause: original_exception)
13
41
  end
14
42
  end
15
43
 
16
- DecodeError = LoadError = ParseError # Legacy support
44
+ # Legacy aliases for backward compatibility
45
+ DecodeError = LoadError = ParseError
17
46
  end
@@ -391,7 +391,7 @@ module MultiJson
391
391
  end
392
392
 
393
393
  def strenc(s)
394
- t = StringIO.new
394
+ t = StringIO.new(String.new(encoding: Encoding::UTF_8))
395
395
  t.putc('"')
396
396
  r = 0
397
397
 
@@ -1,17 +1,28 @@
1
1
  module MultiJson
2
+ # Version information for MultiJson
3
+ #
4
+ # @api private
2
5
  class Version
6
+ # Major version number
3
7
  MAJOR = 1 unless defined? MultiJson::Version::MAJOR
4
- MINOR = 18 unless defined? MultiJson::Version::MINOR
8
+ # Minor version number
9
+ MINOR = 19 unless defined? MultiJson::Version::MINOR
10
+ # Patch version number
5
11
  PATCH = 0 unless defined? MultiJson::Version::PATCH
12
+ # Pre-release version suffix
6
13
  PRE = nil unless defined? MultiJson::Version::PRE
7
14
 
8
15
  class << self
9
- # @return [String]
16
+ # Return the version string
17
+ #
18
+ # @api private
19
+ # @return [String] version in semver format
10
20
  def to_s
11
21
  [MAJOR, MINOR, PATCH, PRE].compact.join(".")
12
22
  end
13
23
  end
14
24
  end
15
25
 
26
+ # Current version string in semver format
16
27
  VERSION = Version.to_s.freeze
17
28
  end
data/lib/multi_json.rb CHANGED
@@ -3,170 +3,229 @@ require_relative "multi_json/version"
3
3
  require_relative "multi_json/adapter_error"
4
4
  require_relative "multi_json/parse_error"
5
5
  require_relative "multi_json/options_cache"
6
-
6
+ require_relative "multi_json/adapter_selector"
7
+
8
+ # A unified interface for JSON libraries in Ruby
9
+ #
10
+ # MultiJson allows swapping between JSON backends without changing your code.
11
+ # It auto-detects available JSON libraries and uses the fastest one available.
12
+ #
13
+ # @example Basic usage
14
+ # MultiJson.load('{"foo":"bar"}') #=> {"foo" => "bar"}
15
+ # MultiJson.dump({foo: "bar"}) #=> '{"foo":"bar"}'
16
+ #
17
+ # @example Specifying an adapter
18
+ # MultiJson.use(:oj)
19
+ # MultiJson.load('{"foo":"bar"}', adapter: :json_gem)
20
+ #
21
+ # @api public
7
22
  module MultiJson
8
- include Options
9
- extend self
23
+ extend Options
24
+ extend AdapterSelector
10
25
 
11
- def default_options=(value)
12
- Kernel.warn "MultiJson.default_options setter is deprecated\nUse MultiJson.load_options and MultiJson.dump_options instead"
26
+ # @!visibility private
27
+ module_function
28
+
29
+ # @!group Configuration
13
30
 
31
+ # Set default options for both load and dump operations
32
+ #
33
+ # @api private
34
+ # @deprecated Use {.load_options=} and {.dump_options=} instead
35
+ # @param value [Hash] options hash
36
+ # @return [Hash] the options hash
37
+ # @example
38
+ # MultiJson.default_options = {symbolize_keys: true}
39
+ def default_options=(value)
40
+ Kernel.warn "MultiJson.default_options setter is deprecated\n" \
41
+ "Use MultiJson.load_options and MultiJson.dump_options instead"
14
42
  self.load_options = self.dump_options = value
15
43
  end
16
44
 
45
+ # Get the default options
46
+ #
47
+ # @api private
48
+ # @deprecated Use {.load_options} or {.dump_options} instead
49
+ # @return [Hash] the current load options
50
+ # @example
51
+ # MultiJson.default_options #=> {}
17
52
  def default_options
18
- Kernel.warn "MultiJson.default_options is deprecated\nUse MultiJson.load_options or MultiJson.dump_options instead"
19
-
53
+ Kernel.warn "MultiJson.default_options is deprecated\n" \
54
+ "Use MultiJson.load_options or MultiJson.dump_options instead"
20
55
  load_options
21
56
  end
22
57
 
58
+ # @deprecated These methods are no longer used
23
59
  %w[cached_options reset_cached_options!].each do |method_name|
24
- define_method method_name do |*|
60
+ define_method(method_name) do |*|
25
61
  Kernel.warn "MultiJson.#{method_name} method is deprecated and no longer used."
26
62
  end
27
63
  end
28
64
 
29
- ALIASES = {"jrjackson" => "jr_jackson"}.freeze
65
+ # Legacy alias for adapter name mappings
66
+ ALIASES = AdapterSelector::ALIASES
30
67
 
68
+ # Maps adapter symbols to their require paths for auto-loading
31
69
  REQUIREMENT_MAP = {
70
+ fast_jsonparser: "fast_jsonparser",
32
71
  oj: "oj",
33
72
  yajl: "yajl",
34
73
  jr_jackson: "jrjackson",
35
74
  json_gem: "json",
36
- gson: "gson",
37
- json_pure: "json"
75
+ gson: "gson"
38
76
  }.freeze
39
77
 
40
- # The default adapter based on what you currently
41
- # have loaded and installed.
42
- def default_adapter
43
- adapter = loaded_adapter || installable_adapter
44
- return adapter if adapter
45
-
46
- @default_adapter_warning_shown ||= begin
47
- Kernel.warn(
48
- "[WARNING] MultiJson is using the default adapter (ok_json). " \
49
- "We recommend loading a different JSON library to improve performance."
50
- )
51
- true
52
- end
53
-
54
- :ok_json
78
+ class << self
79
+ # Returns the default adapter name (alias for default_adapter)
80
+ #
81
+ # @api public
82
+ # @deprecated Use {.default_adapter} instead
83
+ # @return [Symbol] the default adapter name
84
+ # @example
85
+ # MultiJson.default_engine #=> :oj
86
+ alias_method :default_engine, :default_adapter
55
87
  end
56
88
 
57
- alias_method :default_engine, :default_adapter
58
-
59
- # Get the current adapter class.
60
- def adapter
61
- return @adapter if defined?(@adapter) && @adapter
89
+ # @!endgroup
62
90
 
63
- use nil # load default adapter
91
+ # @!group Adapter Management
64
92
 
65
- @adapter
93
+ # Returns the current adapter class
94
+ #
95
+ # @api private
96
+ # @return [Class] the current adapter class
97
+ # @example
98
+ # MultiJson.adapter #=> MultiJson::Adapters::Oj
99
+ def adapter
100
+ @adapter ||= use(nil)
66
101
  end
102
+
103
+ # Returns the current adapter class (alias for adapter)
104
+ #
105
+ # @api private
106
+ # @deprecated Use {.adapter} instead
107
+ # @return [Class] the current adapter class
108
+ # @example
109
+ # MultiJson.engine #=> MultiJson::Adapters::Oj
67
110
  alias_method :engine, :adapter
68
111
 
69
- # Set the JSON parser utilizing a symbol, string, or class.
70
- # Supported by default are:
112
+ # Sets the adapter to use for JSON operations
71
113
  #
72
- # * <tt>:oj</tt>
73
- # * <tt>:json_gem</tt>
74
- # * <tt>:json_pure</tt>
75
- # * <tt>:ok_json</tt>
76
- # * <tt>:yajl</tt>
77
- # * <tt>:gson</tt> (JRuby only)
78
- # * <tt>:jr_jackson</tt> (JRuby only)
114
+ # @api private
115
+ # @param new_adapter [Symbol, String, Module, nil] adapter specification
116
+ # @return [Class] the loaded adapter class
117
+ # @example
118
+ # MultiJson.use(:oj)
79
119
  def use(new_adapter)
80
120
  @adapter = load_adapter(new_adapter)
81
121
  ensure
82
122
  OptionsCache.reset
83
123
  end
124
+
125
+ # Sets the adapter to use for JSON operations
126
+ #
127
+ # @api private
128
+ # @return [Class] the loaded adapter class
129
+ # @example
130
+ # MultiJson.adapter = :json_gem
84
131
  alias_method :adapter=, :use
132
+
133
+ # Sets the adapter to use for JSON operations
134
+ #
135
+ # @api private
136
+ # @deprecated Use {.adapter=} instead
137
+ # @return [Class] the loaded adapter class
138
+ # @example
139
+ # MultiJson.engine = :json_gem
85
140
  alias_method :engine=, :use
141
+ module_function :adapter=, :engine=
86
142
 
87
- def load_adapter(new_adapter)
88
- case new_adapter
89
- when String, Symbol
90
- load_adapter_from_string_name new_adapter.to_s
91
- when NilClass, FalseClass
92
- load_adapter default_adapter
93
- when Class, Module
94
- new_adapter
95
- else
96
- raise ::LoadError, new_adapter
97
- end
98
- rescue ::LoadError => e
99
- raise(AdapterError.build(e), cause: e)
100
- end
143
+ # @!endgroup
101
144
 
102
- # Decode a JSON string into Ruby.
103
- #
104
- # <b>Options</b>
145
+ # @!group JSON Operations
146
+
147
+ # Parses a JSON string into a Ruby object
105
148
  #
106
- # <tt>:symbolize_keys</tt> :: If true, will use symbols instead of strings for the keys.
107
- # <tt>:adapter</tt> :: If set, the selected adapter will be used for this call.
149
+ # @api private
150
+ # @param string [String, #read] JSON string or IO-like object
151
+ # @param options [Hash] parsing options (adapter-specific)
152
+ # @return [Object] parsed Ruby object
153
+ # @raise [ParseError] if parsing fails
154
+ # @example
155
+ # MultiJson.load('{"foo":"bar"}') #=> {"foo" => "bar"}
108
156
  def load(string, options = {})
109
- adapter = current_adapter(options)
110
- begin
111
- adapter.load(string, options)
112
- rescue adapter::ParseError => e
113
- raise(ParseError.build(e, string), cause: e)
114
- end
157
+ adapter_class = current_adapter(options)
158
+ adapter_class.load(string, options)
159
+ rescue adapter_class::ParseError => e
160
+ raise ParseError.build(e, string)
115
161
  end
162
+
163
+ # Parses a JSON string into a Ruby object
164
+ #
165
+ # @api private
166
+ # @return [Object] parsed Ruby object
167
+ # @example
168
+ # MultiJson.decode('{"foo":"bar"}') #=> {"foo" => "bar"}
116
169
  alias_method :decode, :load
117
170
 
171
+ # Returns the adapter to use for the given options
172
+ #
173
+ # @api private
174
+ # @param options [Hash] options that may contain :adapter key
175
+ # @return [Class] adapter class
176
+ # @example
177
+ # MultiJson.current_adapter(adapter: :oj) #=> MultiJson::Adapters::Oj
118
178
  def current_adapter(options = {})
119
- if (new_adapter = options[:adapter])
120
- load_adapter(new_adapter)
121
- else
122
- adapter
123
- end
179
+ options ||= {}
180
+ adapter_override = options[:adapter]
181
+ adapter_override ? load_adapter(adapter_override) : adapter
124
182
  end
125
183
 
126
- # Encodes a Ruby object as JSON.
184
+ # Serializes a Ruby object to a JSON string
185
+ #
186
+ # @api private
187
+ # @param object [Object] object to serialize
188
+ # @param options [Hash] serialization options (adapter-specific)
189
+ # @return [String] JSON string
190
+ # @example
191
+ # MultiJson.dump({foo: "bar"}) #=> '{"foo":"bar"}'
127
192
  def dump(object, options = {})
128
193
  current_adapter(options).dump(object, options)
129
194
  end
195
+
196
+ # Serializes a Ruby object to a JSON string
197
+ #
198
+ # @api private
199
+ # @return [String] JSON string
200
+ # @example
201
+ # MultiJson.encode({foo: "bar"}) #=> '{"foo":"bar"}'
130
202
  alias_method :encode, :dump
131
203
 
132
- # Executes passed block using specified adapter.
204
+ # Executes a block using the specified adapter
205
+ #
206
+ # @api private
207
+ # @param new_adapter [Symbol, String, Module] adapter to use
208
+ # @yield block to execute with the temporary adapter
209
+ # @return [Object] result of the block
210
+ # @example
211
+ # MultiJson.with_adapter(:json_gem) { MultiJson.dump({}) }
133
212
  def with_adapter(new_adapter)
134
- old_adapter = adapter
213
+ previous_adapter = adapter
135
214
  self.adapter = new_adapter
136
215
  yield
137
216
  ensure
138
- self.adapter = old_adapter
139
- end
140
- alias_method :with_engine, :with_adapter
141
-
142
- private
143
-
144
- # Checks for already loaded adapters and returns the first match
145
- def loaded_adapter
146
- return :oj if defined?(::Oj)
147
- return :yajl if defined?(::Yajl)
148
- return :jr_jackson if defined?(::JrJackson)
149
- return :json_gem if defined?(::JSON::Ext::Parser)
150
- return :gson if defined?(::Gson)
151
-
152
- nil
217
+ self.adapter = previous_adapter
153
218
  end
154
219
 
155
- # Attempts to load and return the first installable adapter
156
- def installable_adapter
157
- REQUIREMENT_MAP.each do |adapter, library|
158
- require library
159
- return adapter
160
- rescue ::LoadError
161
- next
162
- end
163
- nil
164
- end
220
+ # Executes a block using the specified adapter
221
+ #
222
+ # @api private
223
+ # @deprecated Use {.with_adapter} instead
224
+ # @return [Object] result of the block
225
+ # @example
226
+ # MultiJson.with_engine(:json_gem) { MultiJson.dump({}) }
227
+ alias_method :with_engine, :with_adapter
228
+ module_function :with_engine
165
229
 
166
- def load_adapter_from_string_name(name)
167
- normalized_name = ALIASES.fetch(name, name).to_s
168
- require "multi_json/adapters/#{normalized_name.downcase}"
169
- klass_name = normalized_name.split("_").map(&:capitalize).join
170
- MultiJson::Adapters.const_get(klass_name)
171
- end
230
+ # @!endgroup
172
231
  end