├── .rspec ├── CHANGELOG.md ├── .standard.yml ├── lib ├── hasharay_ext │ └── version.rb └── hasharay_ext.rb ├── bin ├── setup └── console ├── .gitignore ├── Rakefile ├── Gemfile ├── spec ├── spec_helper.rb └── hasharay_ext_spec.rb ├── .github └── workflows │ └── main.yml ├── hasharay_ext.gemspec ├── LICENSE.txt ├── Gemfile.lock └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | 3 | ## [0.1.0] - 2022-02-03 4 | 5 | - Initial release 6 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | # For available configuration options, see: 2 | # https://github.com/testdouble/standard 3 | -------------------------------------------------------------------------------- /lib/hasharay_ext/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HasharayExt 4 | VERSION = "0.1.0" 5 | end 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require "standard/rake" 9 | 10 | task default: %i[spec standard] 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in hasharay_ext.gemspec 6 | gemspec 7 | 8 | gem "activesupport" 9 | 10 | gem "rake", "~> 13.0" 11 | 12 | gem "rspec", "~> 3.0" 13 | 14 | gem "standard", "~> 1.3" 15 | 16 | gem "pry" 17 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "hasharay_ext" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require "irb" 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hasharay_ext" 4 | 5 | RSpec.configure do |config| 6 | # Enable flags like --only-failures and --next-failure 7 | config.example_status_persistence_file_path = ".rspec_status" 8 | 9 | # Disable RSpec exposing methods globally on `Module` and `main` 10 | config.disable_monkey_patching! 11 | 12 | config.expect_with :rspec do |c| 13 | c.syntax = :expect 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | name: Ruby ${{ matrix.ruby }} 14 | strategy: 15 | matrix: 16 | ruby: 17 | - '2.6.3' 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Ruby 22 | uses: ruby/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | bundler-cache: true 26 | - name: Run the default task 27 | run: bundle exec rake 28 | -------------------------------------------------------------------------------- /hasharay_ext.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/hasharay_ext/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "hasharay_ext" 7 | spec.version = HasharayExt::VERSION 8 | spec.authors = ["Igor Kasyanchuk"] 9 | spec.email = ["igorkasyanchuk@gmail.com"] 10 | 11 | spec.summary = "Useful method to fetch data from complex hashes and arrays" 12 | spec.description = "Useful method to fetch data from complex hashes and arrays" 13 | spec.homepage = "https://github.com/igorkasyanchuk/hasharay_ext" 14 | spec.license = "MIT" 15 | 16 | spec.metadata["homepage_uri"] = spec.homepage 17 | 18 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 19 | `git ls-files -z`.split("\x0").reject do |f| 20 | (f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) 21 | end 22 | end 23 | spec.bindir = "exe" 24 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 25 | spec.require_paths = ["lib"] 26 | 27 | spec.add_dependency "activesupport" 28 | spec.add_development_dependency "pry" 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Igor Kasyanchuk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | hasharay_ext (0.1.0) 5 | activesupport 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activesupport (6.1.4.4) 11 | concurrent-ruby (~> 1.0, >= 1.0.2) 12 | i18n (>= 1.6, < 2) 13 | minitest (>= 5.1) 14 | tzinfo (~> 2.0) 15 | zeitwerk (~> 2.3) 16 | ast (2.4.2) 17 | coderay (1.1.3) 18 | concurrent-ruby (1.1.9) 19 | diff-lcs (1.5.0) 20 | i18n (1.9.1) 21 | concurrent-ruby (~> 1.0) 22 | method_source (1.0.0) 23 | minitest (5.15.0) 24 | parallel (1.21.0) 25 | parser (3.1.0.0) 26 | ast (~> 2.4.1) 27 | pry (0.14.1) 28 | coderay (~> 1.1) 29 | method_source (~> 1.0) 30 | rainbow (3.1.1) 31 | rake (13.0.6) 32 | regexp_parser (2.2.0) 33 | rexml (3.2.5) 34 | rspec (3.10.0) 35 | rspec-core (~> 3.10.0) 36 | rspec-expectations (~> 3.10.0) 37 | rspec-mocks (~> 3.10.0) 38 | rspec-core (3.10.2) 39 | rspec-support (~> 3.10.0) 40 | rspec-expectations (3.10.2) 41 | diff-lcs (>= 1.2.0, < 2.0) 42 | rspec-support (~> 3.10.0) 43 | rspec-mocks (3.10.3) 44 | diff-lcs (>= 1.2.0, < 2.0) 45 | rspec-support (~> 3.10.0) 46 | rspec-support (3.10.3) 47 | rubocop (1.25.0) 48 | parallel (~> 1.10) 49 | parser (>= 3.1.0.0) 50 | rainbow (>= 2.2.2, < 4.0) 51 | regexp_parser (>= 1.8, < 3.0) 52 | rexml 53 | rubocop-ast (>= 1.15.1, < 2.0) 54 | ruby-progressbar (~> 1.7) 55 | unicode-display_width (>= 1.4.0, < 3.0) 56 | rubocop-ast (1.15.1) 57 | parser (>= 3.0.1.1) 58 | rubocop-performance (1.13.2) 59 | rubocop (>= 1.7.0, < 2.0) 60 | rubocop-ast (>= 0.4.0) 61 | ruby-progressbar (1.11.0) 62 | standard (1.7.0) 63 | rubocop (= 1.25.0) 64 | rubocop-performance (= 1.13.2) 65 | tzinfo (2.0.4) 66 | concurrent-ruby (~> 1.0) 67 | unicode-display_width (2.1.0) 68 | zeitwerk (2.5.4) 69 | 70 | PLATFORMS 71 | x86_64-linux 72 | 73 | DEPENDENCIES 74 | activesupport 75 | hasharay_ext! 76 | pry 77 | rake (~> 13.0) 78 | rspec (~> 3.0) 79 | standard (~> 1.3) 80 | 81 | BUNDLED WITH 82 | 2.3.3 83 | -------------------------------------------------------------------------------- /lib/hasharay_ext.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/all" 4 | require_relative "hasharay_ext/version" 5 | 6 | module HasharayExt 7 | class Error < StandardError; end 8 | 9 | module Interface 10 | def fpath!(path, separator: ".", default: nil) 11 | fpath(path, strict: true, separator: separator, default: default) 12 | end 13 | alias_method :fetch_path!, :fpath! 14 | end 15 | 16 | class Logic 17 | attr_reader :data, :strict, :separator, :default 18 | 19 | def initialize(data, strict: true, separator: ".", default: nil) 20 | @data = data 21 | @strict = strict 22 | @separator = separator 23 | @default = default 24 | end 25 | 26 | def get(path) 27 | raise ArgumentError.new("Not specified key") if path.blank? 28 | 29 | object = data.clone 30 | tree = path.split(separator) 31 | 32 | tree.each_with_index do |raw, index| 33 | raise ArgumentError.new("Not specified key") if raw.blank? 34 | 35 | keywords = raw.split("+") 36 | if index == (tree.size - 1) && keywords.size > 1 37 | # last iteration 38 | case object 39 | when Array 40 | return keywords.map do |keyword| 41 | fetch(object, keyword) 42 | end.transpose 43 | when Hash 44 | return keywords.each_with_object({}) { |e, res| 45 | res[e] = fetch(object, e) 46 | } 47 | end 48 | else 49 | # every key 50 | object = fetch(object, raw) 51 | end 52 | end 53 | object 54 | end 55 | 56 | private 57 | 58 | def fetch(object, key) 59 | case object 60 | when Hash 61 | e = object.fetch(key, strict ? invalid_key!(key, object) : default) 62 | if e.is_a?(Proc) 63 | e.call 64 | else 65 | object = e 66 | end 67 | when Array 68 | object = object.fpath(key, strict: strict, separator: separator) 69 | end 70 | object 71 | end 72 | 73 | def invalid_key!(key, object) 74 | proc { raise(ArgumentError.new("Key `#{key}` not found on attribute ##{object} strict: #{strict}, separator: #{separator}")) } 75 | end 76 | end 77 | end 78 | 79 | class Array 80 | include HasharayExt::Interface 81 | 82 | def fpath(key, strict: false, separator: ".", default: nil) 83 | map do |e| 84 | e.present? ? e.fpath(key, strict: strict, separator: separator, default: default) : default 85 | end 86 | end 87 | alias_method :fetch_path, :fpath 88 | end 89 | 90 | class Hash 91 | include HasharayExt::Interface 92 | 93 | def fpath(path, strict: false, separator: ".", default: nil) 94 | HasharayExt::Logic.new(clone.deep_stringify_keys, strict: strict, separator: separator, default: default).get(path) 95 | end 96 | alias_method :fetch_path, :fpath 97 | end 98 | -------------------------------------------------------------------------------- /spec/hasharay_ext_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe HasharayExt do 4 | it "has a version number" do 5 | expect(HasharayExt::VERSION).not_to be nil 6 | end 7 | 8 | it "example.1" do 9 | c = {} 10 | expect(c.fpath("a")).to eq(nil) 11 | expect(c.fetch_path("a")).to eq(nil) 12 | expect(c.fetch_path("a", default: 42)).to eq(42) 13 | expect { c.fpath!("a") }.to raise_error(ArgumentError) 14 | expect { c.fpath!(nil) }.to raise_error(ArgumentError) 15 | expect { c.fpath!("#") }.to raise_error(ArgumentError) 16 | expect { c.fetch_path!("#") }.to raise_error(ArgumentError) 17 | end 18 | 19 | it "example array" do 20 | c = [{name: "igor"}, {name: "john"}] 21 | expect(c.fpath("name")).to eq(["igor", "john"]) 22 | 23 | c = [{user: {first_name: "john"}}, {user: {first_name: "bob"}}] 24 | expect(c.fpath("user.first_name")).to eq(["john", "bob"]) 25 | expect(c.fpath("user.last_name")).to eq([nil, nil]) 26 | expect(c.fpath("user.last_name", default: "user")).to eq(["user", "user"]) 27 | expect(c.fetch_path("user.last_name")).to eq([nil, nil]) 28 | 29 | expect(c.fpath!("user.first_name")).to eq(["john", "bob"]) 30 | expect { c.fpath!("user.last_name") }.to raise_error(ArgumentError) 31 | expect { c.fetch_path!("user.last_name") }.to raise_error(ArgumentError) 32 | end 33 | 34 | it "example.2" do 35 | c = { 36 | name: "john", 37 | dob: Date.today, 38 | projects: [ 39 | {name: "A", locations: ["Kyiv"]}, 40 | {name: "B", locations: ["Paris", "Berlin"]} 41 | ], 42 | position: { 43 | company: { 44 | team: "position1", 45 | office: "position2", 46 | other: { 47 | status: "unknown", 48 | notes: ["note a", "note b"], 49 | summaries: [ 50 | {worker: "John", level: "middle"}, 51 | {worker: "Bob", level: "senior"} 52 | ] 53 | } 54 | } 55 | }, 56 | locations: [ 57 | { 58 | city: "Kyiv", 59 | country: "Ukraine" 60 | }, 61 | { 62 | city: "Odessa", 63 | country: "Ukraine" 64 | } 65 | ] 66 | } 67 | expect(c.fpath("not_exising_name")).to eq(nil) 68 | expect(c.fpath("not_exising_projects.not_exising_name")).to eq(nil) 69 | expect(c.fpath("not_exising_projects.not_exising_name", default: 42)).to eq(42) 70 | expect(c.fpath("name")).to eq("john") 71 | expect(c.fpath("projects.name")).to eq(["A", "B"]) 72 | expect(c.fpath("projects.locations")).to eq([["Kyiv"], ["Paris", "Berlin"]]) 73 | expect(c.fpath!("position.company.other.summaries")).to eq([{"level" => "middle", "worker" => "John"}, {"level" => "senior", "worker" => "Bob"}]) 74 | expect(c.fpath("position.company.other.status")).to eq("unknown") 75 | expect(c.fpath("position.company.team+office")).to eq({"team" => "position1", "office" => "position2"}) 76 | 77 | # way 1 78 | expect(c.dig(:position, :company, :other, :summaries).map { |e| e[:worker] }).to eq(["John", "Bob"]) 79 | 80 | # way 2 NEW WAY 81 | expect(c.fpath("position.company.other.summaries.worker")).to eq(["John", "Bob"]) 82 | 83 | expect(c.fpath("position.company.other.summaries.worker+level")).to eq([["John", "middle"], ["Bob", "senior"]]) 84 | end 85 | 86 | it "example.3" do 87 | h = { 88 | "topics" => 89 | {"nodes" => 90 | [ 91 | {"node" => {"topic" => {"name" => "xxx"}}}, 92 | {"node" => {"topic" => {"name" => "yyy"}}} 93 | ]} 94 | } 95 | expect(h.fpath("topics.nodes.node.topic.name")).to eq(["xxx", "yyy"]) 96 | expect(h.fpath("topics.nodes.node.topic")).to eq([{"name" => "xxx"}, {"name" => "yyy"}]) 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hash/Array Extenstion 2 | 3 | [![RailsJazz](https://github.com/igorkasyanchuk/rails_time_travel/blob/main/docs/my_other.svg?raw=true)](https://www.railsjazz.com) 4 | [![https://www.patreon.com/igorkasyanchuk](https://github.com/igorkasyanchuk/rails_time_travel/blob/main/docs/patron.svg?raw=true)](https://www.patreon.com/igorkasyanchuk) 5 | 6 | [!["Buy Me A Coffee"](https://github.com/igorkasyanchuk/get-smart/blob/main/docs/snapshot-bmc-button-small.png?raw=true)](https://buymeacoffee.com/igorkasyanchuk) 7 | 8 | The idea of this gem is to simplify fetching records from the Hash/Array. 9 | I had a need to read values from the complex hashes, and I was tired of doing lots of "dig/fetch". Code was just ugly. 10 | 11 | So, initial idea was to create a way to "query" Hash/Array similiar how we are querying CSS, but later idea was changed a little. 12 | 13 | Right now I've extracted my code into this gem and now can do the following: 14 | 15 | Instead of: 16 | ```ruby 17 | h.fetch(:projects, []).map{|e| e[:name]} 18 | ``` 19 | I can just write: 20 | ```ruby 21 | h.fpath('projects.name') 22 | ``` 23 | 24 | Even with a such simple example you can see that the code is much readable. 25 | It was a nice win for my project and at least I'm very happy with it :) 26 | 27 | Check more examples below to see if it can be useful for you too. 28 | 29 | ## More Complex Example 30 | 31 | ```ruby 32 | # ----- 33 | # INITIAL DATA (see usage below) 34 | # ----- 35 | hash = { 36 | name: "john", 37 | dob: Date.today, 38 | projects: [ {name: "A", locations: ["Kyiv"]}, {name: "B", locations: ["Paris", "Berlin"]} ], 39 | position: { 40 | company: { 41 | team: "position1", 42 | office: "position2", 43 | other: { 44 | status: "unknown", 45 | notes: ["note a", "note b"], 46 | summaries: [ 47 | {worker: "John", level: "middle"}, 48 | {worker: "Bob", level: "senior"} 49 | ] 50 | } 51 | } 52 | }, 53 | locations: [ { city: "Kyiv", country: "Ukraine" }, { city: "Odessa", country: "Ukraine"}] 54 | } 55 | 56 | # ----- 57 | # USAGE with HASH 58 | # ----- 59 | hash.fpath("name") # => "john" 60 | hash.fetch_path("projects.name") # => ["A", "B"] 61 | hash.fpath("projects.locations") # => [["Kyiv"], ["Paris", "Berlin"]] 62 | hash.fpath!("position.company.other.summaries") # => [{"level" => "middle", "worker" => "John"}, {"level" => "senior", "worker" => "Bob"}] 63 | hash.fetch_path!("position.company.other.status") # => "unknown" 64 | hash.fpath("position.company.team+office") # => {"team" => "position1", "office" => "position2"} 65 | hash.fpath("not_exising_name") # => nil 66 | hash.fpath("not_exising_projects.not_exising_name") # => nil 67 | hash.fpath("not_exising_projects.not_exising_name", default: 42) # => 42 68 | 69 | # USAGE with ARRAY 70 | array = [{name: "igor"}, {name: "john"}] 71 | array.fpath("name") # => ["igor", "john"] 72 | 73 | array = [{user: {first_name: "john"}}, {user: {first_name: "bob"}}] 74 | array.fpath("user.first_name") # => ["john", "bob"] 75 | ``` 76 | 77 | ### Methods & Options 78 | 79 | `fpath` or `fetch_path` will return vakue based on path to the value. If nothing - nil. 80 | `fpath!` or `fetch_path!` working same way as `fpath` but raise error is some key is not available. 81 | 82 | Available options: `def fpath(key, strict: false, separator: ".", default: nil)`. 83 | 84 | Pay attention that if you have in your keys dots, you need to change separator. On my project I've only one work textual keys. 85 | 86 | You can also return multiple values, pay attention to the `hash.fpath("position.company.team+office")`. Note that "+" works only for the last key in the queried "path". 87 | 88 | Note that keys in the returned hash are stringified. 89 | 90 | More examples available in the specs. 91 | 92 | ## Installation 93 | 94 | Add this line to your application's Gemfile: 95 | 96 | ```ruby 97 | gem 'hasharay_ext' 98 | ``` 99 | 100 | And then execute: 101 | 102 | $ bundle install 103 | 104 | Or install it yourself as: 105 | 106 | $ gem install hasharay_ext 107 | 108 | ## Ideas 109 | 110 | - Add support for "*" operator to search in hash/array for needed values. 111 | 112 | ## Development 113 | 114 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 115 | 116 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 117 | 118 | ## Contributing 119 | 120 | Bug reports and pull requests are welcome on GitHub at https://github.com/igorkasyanchuk/hasharay_ext. 121 | 122 | ## License 123 | 124 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 125 | 126 | [](https://www.railsjazz.com/?utm_source=github&utm_medium=bottom&utm_campaign=hasharay_ext) 128 | 129 | [!["Buy Me A Coffee"](https://github.com/igorkasyanchuk/get-smart/blob/main/docs/snapshot-bmc-button.png?raw=true)](https://buymeacoffee.com/igorkasyanchuk) 130 | --------------------------------------------------------------------------------