Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Mash::UnderscoreKeys Extensions #566

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -13,6 +13,7 @@ Any violations of this scheme are considered to be bugs.
### Added

* Your contribution here.
* [#566](https://github.com/hashie/hashie/pull/566): Added `Mash::UnderscoreKeys` extensions for conversion of all keys to underscore - [@arianf](https://github.com/arianf)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just missing a period.


### Changed

Expand Down
24 changes: 24 additions & 0 deletions README.md
Expand Up @@ -783,6 +783,30 @@ end

However, on Rubies less than 2.0, this means that every key you send to the Mash will generate a symbol. Since symbols are not garbage-collected on older versions of Ruby, this can cause a slow memory leak when using a symbolized Mash with data generated from user input.

### UnderscoreKeys
This extension can be mixed into a Mash to change the default behavior of converting keys to be underscore. After mixing this extension into a Mash, the Mash will convert all string keys to underscore. It can be useful to use with external source hashes, which maybe contain hyphens or CamelCase.

```ruby
class UnderscoreMash < ::Hashie::Mash
include Hashie::Extensions::Mash::UnderscoreKeys
end

mash = UnderscoreMash.new
mash.updatedAt = 'Today' #=> 'Today'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: Can we lowercase today so that it matches the below tomorrow pls.

mash.updatedAt #=> 'Today'
mash[:updated_at] #=> 'Today'
mash['updated_at'] #=> 'Today'
mash.updated_at #=> 'Today'
mash.to_hash #=> {"updated_at"=>true}
```

The other benefit is hashes that have hyphens can be accessed with methods
```ruby
mash = UnderscoreMash.new('created-at': 'tomorrow') #=> {"created_at"=>"tomorrow"}

mash.created_at #=> 'tomorrow'
```

### DefineAccessors

This extension can be mixed into a Mash so it makes it behave like `OpenStruct`. It reduces the overhead of `method_missing?` magic by lazily defining field accessors when they're requested.
Expand Down
1 change: 1 addition & 0 deletions lib/hashie.rb
Expand Up @@ -50,6 +50,7 @@ module Mash
autoload :SafeAssignment, 'hashie/extensions/mash/safe_assignment'
autoload :SymbolizeKeys, 'hashie/extensions/mash/symbolize_keys'
autoload :DefineAccessors, 'hashie/extensions/mash/define_accessors'
autoload :UnderscoreKeys, 'hashie/extensions/mash/underscore_keys'
end

module Array
Expand Down
40 changes: 40 additions & 0 deletions lib/hashie/extensions/mash/underscore_keys.rb
@@ -0,0 +1,40 @@
module Hashie
module Extensions
module Mash
# Overrides the indifferent access of a Mash to keep keys in
# underscore format.
#
# @example
# class UnderscoreMash < ::Hashie::Mash
# include Hashie::Extensions::Mash::UnderscoreKeys
# end
#
# mash = UnderscoreMash.new(symbolKey: { dataFrom: { java: true, javaScript: true } })
# mash.symbol_key.data_from.java #=> true
# mash.symbolKey.dataFrom.java_script #=> true
module UnderscoreKeys
def self.included(base)
raise ArgumentError, "#{base} must descent from Hashie::Mash" unless base <= Hashie::Mash
end

private

def _underscore(string)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I went pretty old school and grabbed the version from 3.1 haha
https://github.com/rails/rails/blob/4dacedf983257aef38a8ebedb2d9a9c8fead8238/activesupport/lib/active_support/inflector/methods.rb#L48

I can update it to reflect the new version, I think there are likely performance and edge case improvements.

The one difference to this method that I added was converting spaces to underscores as well.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure we want a different "underscore" behavior than ActiveSupport? Why?

Copy link
Author

@arianf arianf Sep 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I was thinking this could be slightly different than Rails. The intent here is making method names compatible with the Ruby's method naming conversions. createdAt -> created_at

But I think it could also go a step further, "created at" also does not follow the method naming convention, as a space is an illegal character. So my thought was we could convert them to underscore as well.

Maybe this should be called Hashie::Extensions::Mash::MethodSafeKeys to not cause confusion. What do you think?

But I'm also okay just keeping it exactly the same as rails. Either way works for me, just feel the former is more convenient

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about implementing a generic extension that lets me transform keys whichever way I want (TransformKeys) as a custom behavior, and then deriving it into a bunch of specific behaviors such as a rails-compatible UnderscoreKeys?

string.gsub(/::/, '/')
.gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
.gsub(/([a-z\d])([A-Z])/, '\1_\2')
.tr('- ', '_')
.downcase
end

# Ensures all keys are underscore formatting.
#
# @param [Object, String, Symbol] key the key to access.
# @return [Object] the value assigned to the key.
def convert_key(key)
_underscore(key.to_s) if key.is_a?(String) || key.is_a?(Symbol)
end
end
end
end
end
2 changes: 1 addition & 1 deletion spec/hashie/extensions/autoload_spec.rb
Expand Up @@ -2,7 +2,7 @@
require 'hashie'

describe Hashie::Extensions do
describe 'autloads constants' do
describe 'autoloads constants' do
it { is_expected.to be_const_defined(:MethodAccess) }
it { is_expected.to be_const_defined(:Coercion) }
it { is_expected.to be_const_defined(:DeepMerge) }
Expand Down
88 changes: 88 additions & 0 deletions spec/hashie/extensions/mash/underscrore_keys_spec.rb
@@ -0,0 +1,88 @@
require 'spec_helper'

RSpec.describe Hashie::Extensions::Mash::UnderscoreKeys, :aggregate_failures do
let(:underscore_mash) do
Class.new(Hashie::Mash) do
include Hashie::Extensions::Mash::UnderscoreKeys
end
end

it 'allows access to keys via original name' do
original = {
dataFrom: { java: true, javaScript: true },
DataSource: { GitHub: true },
'created-at': 'today'
}

mash = underscore_mash.new(original)

expect(mash.dataFrom.java).to be(true)
expect(mash[:dataFrom][:java]).to be(true)
expect(mash['dataFrom']['java']).to be(true)

expect(mash.dataFrom.javaScript).to be(true)
expect(mash[:dataFrom][:javaScript]).to be(true)
expect(mash['dataFrom']['javaScript']).to be(true)

expect(mash.DataSource.GitHub).to be(true)
expect(mash[:DataSource][:GitHub]).to be(true)
expect(mash['DataSource']['GitHub']).to be(true)

# can't currently call a method with a hyphen
# expect(mash.call(:'created-at')).to eq('today')
expect(mash[:'created-at']).to eq('today')
expect(mash['created-at']).to eq('today')
end

it 'allows access to underscore key names' do
original = {
dataFrom: { java: true, javaScript: true },
DataSource: { GitHub: true },
'created-at': 'today'
}

mash = underscore_mash.new(original)

expect(mash.data_from.java).to be(true)
expect(mash[:data_from][:java]).to be(true)
expect(mash['data_from']['java']).to be(true)

expect(mash.data_from.java_script).to be(true)
expect(mash[:data_from][:java_script]).to be(true)
expect(mash['data_from']['java_script']).to be(true)

expect(mash.data_source.git_hub).to be(true)
expect(mash[:data_source][:git_hub]).to be(true)
expect(mash['data_source']['git_hub']).to be(true)

expect(mash.created_at).to eq('today')
expect(mash[:'created-at']).to eq('today')
expect(mash['created-at']).to eq('today')
end

it 'allows mixing and matching of underscore and camelCase' do
original = {
dataFrom: { java: true, javaScript: true },
DataSource: { GitHub: true },
'created-at': 'today'
}

mash = underscore_mash.new(original)

expect(mash.dataFrom.java_script).to be(true)
expect(mash[:data_from][:javaScript]).to be(true)
expect(mash['dataFrom']['java_script']).to be(true)

expect(mash.DataSource.git_hub).to be(true)
expect(mash[:data_source][:GitHub]).to be(true)
expect(mash['DataSource']['git_hub']).to be(true)
end

it 'converts spaces to underscore' do
original = { 'hashie mashie': 'mashie hashie' }

mash = underscore_mash.new(original)

expect(mash.hashie_mashie).to eq('mashie hashie')
end
end