From 761da5c030134fa4b8acb170dc2a184bffb80ff2 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Wed, 27 Jan 2021 13:33:55 +0100 Subject: [PATCH] Improve key formatting for nested hashes and disable by default (#497) * 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" }} --- README.md | 19 ++++++ lib/jbuilder.rb | 49 ++++++++++++--- test/jbuilder_test.rb | 143 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 196 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 1c633d97..e3655e62 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/jbuilder.rb b/lib/jbuilder.rb index 43b2536f..37e776fc 100644 --- a/lib/jbuilder.rb +++ b/lib/jbuilder.rb @@ -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 @@ -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: @@ -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. @@ -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. @@ -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) @@ -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 @@ -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 @@ -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) diff --git a/test/jbuilder_test.rb b/test/jbuilder_test.rb index 5f46ea84..d3b37fce 100644 --- a/test/jbuilder_test.rb +++ b/test/jbuilder_test.rb @@ -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 @@ -593,51 +623,121 @@ 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 @@ -645,6 +745,39 @@ class JbuilderTest < ActiveSupport::TestCase 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' }