Skip to content

Commit

Permalink
Improve key formatting for nested hashes and disable by default (#497)
Browse files Browse the repository at this point in the history
* Fix changing key format in child elements

Since #486, key format is applied deeply to hashes and arrays that are
passed to `set!`, `merge!` and `array!`:

    json.key_format! :upcase
    json.set!(:foo, {some: "value"})

    # => {"FOO": {"SOME": "value"}}

    json.key_format! :upcase
    json.merge!({some: "value"})

    # => {"SOME": "value"}

    json.key_format! :upcase
    json.array!([{some: "value"}])

    # => [{"SOME": "value"}]

This also works for arrays and hashes extracted from objects:

    comment = Struct.new(:author).new({ first_name: 'John', last_name: 'Doe' })

    json.key_format! camlize: :lower
    json.set!(:comment, comment, :author)

    # => {"comment": {"author": {"firstName": "John", "lastName": "Doe"}}}

As a side effect of the change, key format is also applied to the
result of nested blocks, making it impossible to change key format in
the scope of a block:

    json.key_format! camelize: :lower
    json.level_one do
      json.key_format! :upcase
      json.value 'two'
    end

    # => jbuilder 2.10.0: {"levelOne": {"VALUE": "two"}}
    # => jbuilder 2.11.0: {"levelOne": {"vALUE": "two"}}

The string "vALUE" results from calling
`"value".upcase.camelize(:lower)`. The same happens when trying to
change the key format inside of an `array!` block.

This happens since key transformation was added in the `_merge_values`
method, which is used both by `merge!` but also when `set!` is called
with a block.

To restore the previous behavior, we pull the `_transform_keys` call
up into `merge!` itself. To make sure extracted hashes and arrays keep
being transformed (see comment/author example above), we apply
`_transform_keys` in `_extract_hash_values` and
`_extract_method_values`.

This also aligns the behavior of `extract!` which was left out in #486:

    comment = {author: { first_name: 'John', last_name: 'Doe' }}

    result = jbuild do |json|
      json.key_format! camelize: :lower
      json.extract! comment, :author
    end

    # => jbuilder 2.10 and 2.11: {"author": { :first_name => "John", :last_name => "Doe" }}
    # => now: {"author": { "firstName": "John", "lastName": "Doe" }}

Finally, to fix `array!`, we make it call `_merge_values` directly
instead of relying on `merge!`. `array!` then has to transform keys
itself when a collection is passed to preserve the new behavior
introduced by #486.

* Disable deeply formatting keys of nested hashes by default

Since #486, key format was also applied to nested hashes that are
passed as values:

     json.key_format! camelize: :lower
     json.settings({some_value: "abc"})

     # => { "settings": { "someValue": "abc" }}

This breaks code that relied on the previous behavior. Add a
`deep_format_keys!` directive that can be used to opt into this new
behavior:

     json.key_format! camelize: :lower
     json.deep_format_keys!
     json.settings({some_value: "abc"})

     # => { "settings": { "someValue": "abc" }}
  • Loading branch information
tf committed Jan 27, 2021
1 parent 18c06fe commit 761da5c
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 15 deletions.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,25 @@ environment.rb for example):
Jbuilder.key_format camelize: :lower
```

By default, key format is not applied to keys of hashes that are
passed to methods like `set!`, `array!` or `merge!`. You can opt into
deeply transforming these as well:

``` ruby
json.key_format! camelize: :lower
json.deep_format_keys!
json.settings([{some_value: "abc"}])

# => { "settings": [{ "someValue": "abc" }]}
```

You can set this globally with the class method `deep_format_keys` (from inside your
environment.rb for example):

``` ruby
Jbuilder.deep_format_keys true
```

## Contributing to Jbuilder

Jbuilder is the work of many contributors. You're encouraged to submit pull requests, propose
Expand Down
49 changes: 39 additions & 10 deletions lib/jbuilder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
class Jbuilder
@@key_formatter = nil
@@ignore_nil = false
@@deep_format_keys = false

def initialize(options = {})
@attributes = {}

@key_formatter = options.fetch(:key_formatter){ @@key_formatter ? @@key_formatter.clone : nil}
@ignore_nil = options.fetch(:ignore_nil, @@ignore_nil)
@deep_format_keys = options.fetch(:deep_format_keys, @@deep_format_keys)

yield self if ::Kernel.block_given?
end
Expand Down Expand Up @@ -131,6 +133,31 @@ def self.ignore_nil(value = true)
@@ignore_nil = value
end

# Deeply apply key format to nested hashes and arrays passed to
# methods like set!, merge! or array!.
#
# Example:
#
# json.key_format! camelize: :lower
# json.settings({some_value: "abc"})
#
# { "settings": { "some_value": "abc" }}
#
# json.key_format! camelize: :lower
# json.deep_format_keys!
# json.settings({some_value: "abc"})
#
# { "settings": { "someValue": "abc" }}
#
def deep_format_keys!(value = true)
@deep_format_keys = value
end

# Same as instance method deep_format_keys! except sets the default.
def self.deep_format_keys(value = true)
@@deep_format_keys = value
end

# Turns the current element into an array and yields a builder to add a hash.
#
# Example:
Expand Down Expand Up @@ -190,10 +217,10 @@ def array!(collection = [], *attributes, &block)
elsif attributes.any?
_map_collection(collection) { |element| extract! element, *attributes }
else
collection.to_a
_format_keys(collection.to_a)
end

merge! array
@attributes = _merge_values(@attributes, array)
end

# Extracts the mentioned attributes or hash elements from the passed object and turns them into attributes of the JSON.
Expand Down Expand Up @@ -244,7 +271,7 @@ def attributes!
# Merges hash, array, or Jbuilder instance into current builder.
def merge!(object)
hash_or_array = ::Jbuilder === object ? object.attributes! : object
@attributes = _merge_values(@attributes, hash_or_array)
@attributes = _merge_values(@attributes, _format_keys(hash_or_array))
end

# Encodes the current builder as JSON.
Expand All @@ -255,11 +282,11 @@ def target!
private

def _extract_hash_values(object, attributes)
attributes.each{ |key| _set_value key, object.fetch(key) }
attributes.each{ |key| _set_value key, _format_keys(object.fetch(key)) }
end

def _extract_method_values(object, attributes)
attributes.each{ |key| _set_value key, object.public_send(key) }
attributes.each{ |key| _set_value key, _format_keys(object.public_send(key)) }
end

def _merge_block(key)
Expand All @@ -273,11 +300,11 @@ def _merge_values(current_value, updates)
if _blank?(updates)
current_value
elsif _blank?(current_value) || updates.nil? || current_value.empty? && ::Array === updates
_format_keys(updates)
updates
elsif ::Array === current_value && ::Array === updates
current_value + _format_keys(updates)
current_value + updates
elsif ::Hash === current_value && ::Hash === updates
current_value.deep_merge(_format_keys(updates))
current_value.deep_merge(updates)
else
raise MergeError.build(current_value, updates)
end
Expand All @@ -288,6 +315,8 @@ def _key(key)
end

def _format_keys(hash_or_array)
return hash_or_array unless @deep_format_keys

if ::Array === hash_or_array
hash_or_array.map { |value| _format_keys(value) }
elsif ::Hash === hash_or_array
Expand All @@ -312,12 +341,12 @@ def _map_collection(collection)
end

def _scope
parent_attributes, parent_formatter = @attributes, @key_formatter
parent_attributes, parent_formatter, parent_deep_format_keys = @attributes, @key_formatter, @deep_format_keys
@attributes = BLANK
yield
@attributes
ensure
@attributes, @key_formatter = parent_attributes, parent_formatter
@attributes, @key_formatter, @deep_format_keys = parent_attributes, parent_formatter, parent_deep_format_keys
end

def _is_collection?(object)
Expand Down
143 changes: 138 additions & 5 deletions test/jbuilder_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -566,6 +566,36 @@ class JbuilderTest < ActiveSupport::TestCase
assert_equal 'one', result['level1']
end

test 'key_format! can be changed in child elements' do
result = jbuild do |json|
json.key_format! camelize: :lower

json.level_one do
json.key_format! :upcase
json.value 'two'
end
end

assert_equal ['levelOne'], result.keys
assert_equal ['VALUE'], result['levelOne'].keys
end

test 'key_format! can be changed in array!' do
result = jbuild do |json|
json.key_format! camelize: :lower

json.level_one do
json.array! [{value: 'two'}] do |object|
json.key_format! :upcase
json.value object[:value]
end
end
end

assert_equal ['levelOne'], result.keys
assert_equal ['VALUE'], result['levelOne'][0].keys
end

test 'key_format! with no parameter' do
result = jbuild do |json|
json.key_format! :upcase
Expand Down Expand Up @@ -593,58 +623,161 @@ class JbuilderTest < ActiveSupport::TestCase
assert_equal ['oats and friends'], result.keys
end

test 'key_format! with merge!' do
test 'key_format! is not applied deeply by default' do
names = { first_name: 'camel', last_name: 'case' }
result = jbuild do |json|
json.key_format! camelize: :lower
json.set! :all_names, names
end

assert_equal %i[first_name last_name], result['allNames'].keys
end

test 'applying key_format! deeply can be enabled per scope' do
names = { first_name: 'camel', last_name: 'case' }
result = jbuild do |json|
json.key_format! camelize: :lower
json.scope do
json.deep_format_keys!
json.set! :all_names, names
end
json.set! :all_names, names
end

assert_equal %w[firstName lastName], result['scope']['allNames'].keys
assert_equal %i[first_name last_name], result['allNames'].keys
end

test 'applying key_format! deeply can be disabled per scope' do
names = { first_name: 'camel', last_name: 'case' }
result = jbuild do |json|
json.key_format! camelize: :lower
json.deep_format_keys!
json.set! :all_names, names
json.scope do
json.deep_format_keys! false
json.set! :all_names, names
end
end

assert_equal %w[firstName lastName], result['allNames'].keys
assert_equal %i[first_name last_name], result['scope']['allNames'].keys
end

test 'applying key_format! deeply can be enabled globally' do
names = { first_name: 'camel', last_name: 'case' }

Jbuilder.deep_format_keys true
result = jbuild do |json|
json.key_format! camelize: :lower
json.set! :all_names, names
end

assert_equal %w[firstName lastName], result['allNames'].keys
Jbuilder.send(:class_variable_set, '@@deep_format_keys', false)
end

test 'deep key_format! with merge!' do
hash = { camel_style: 'for JS' }
result = jbuild do |json|
json.key_format! camelize: :lower
json.deep_format_keys!
json.merge! hash
end

assert_equal ['camelStyle'], result.keys
end

test 'key_format! with merge! deep' do
test 'deep key_format! with merge! deep' do
hash = { camel_style: { sub_attr: 'for JS' } }
result = jbuild do |json|
json.key_format! camelize: :lower
json.deep_format_keys!
json.merge! hash
end

assert_equal ['subAttr'], result['camelStyle'].keys
end

test 'key_format! with set! array of hashes' do
test 'deep key_format! with set! array of hashes' do
names = [{ first_name: 'camel', last_name: 'case' }]
result = jbuild do |json|
json.key_format! camelize: :lower
json.deep_format_keys!
json.set! :names, names
end

assert_equal %w[firstName lastName], result['names'][0].keys
end

test 'key_format! with array! of hashes' do
test 'deep key_format! with set! extracting hash from object' do
comment = Struct.new(:author).new({ first_name: 'camel', last_name: 'case' })
result = jbuild do |json|
json.key_format! camelize: :lower
json.deep_format_keys!
json.set! :comment, comment, :author
end

assert_equal %w[firstName lastName], result['comment']['author'].keys
end

test 'deep key_format! with array! of hashes' do
names = [{ first_name: 'camel', last_name: 'case' }]
result = jbuild do |json|
json.key_format! camelize: :lower
json.deep_format_keys!
json.array! names
end

assert_equal %w[firstName lastName], result[0].keys
end

test 'key_format! with merge! array of hashes' do
test 'deep key_format! with merge! array of hashes' do
names = [{ first_name: 'camel', last_name: 'case' }]
new_names = [{ first_name: 'snake', last_name: 'case' }]
result = jbuild do |json|
json.key_format! camelize: :lower
json.deep_format_keys!
json.array! names
json.merge! new_names
end

assert_equal %w[firstName lastName], result[1].keys
end

test 'deep key_format! is applied to hash extracted from object' do
comment = Struct.new(:author).new({ first_name: 'camel', last_name: 'case' })
result = jbuild do |json|
json.key_format! camelize: :lower
json.deep_format_keys!
json.extract! comment, :author
end

assert_equal %w[firstName lastName], result['author'].keys
end

test 'deep key_format! is applied to hash extracted from hash' do
comment = {author: { first_name: 'camel', last_name: 'case' }}
result = jbuild do |json|
json.key_format! camelize: :lower
json.deep_format_keys!
json.extract! comment, :author
end

assert_equal %w[firstName lastName], result['author'].keys
end

test 'deep key_format! is applied to hash extracted directly from array' do
comments = [Struct.new(:author).new({ first_name: 'camel', last_name: 'case' })]
result = jbuild do |json|
json.key_format! camelize: :lower
json.deep_format_keys!
json.array! comments, :author
end

assert_equal %w[firstName lastName], result[0]['author'].keys
end

test 'default key_format!' do
Jbuilder.key_format camelize: :lower
result = jbuild{ |json| json.camel_style 'for JS' }
Expand Down

0 comments on commit 761da5c

Please sign in to comment.