Last active
December 26, 2015 08:59
-
-
Save mattgoldman/7125774 to your computer and use it in GitHub Desktop.
Ruby module to evaluate a Hash against a set of filters. i.e. Does `{name: "John", age: 21}` pass the following filter?: `age > 20`
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
module HashFilter | |
# Runs filters on Hash and determines whether or not it shall pass | |
# | |
# ==== Attributes | |
# | |
# * +hash+ - +Hash+ to be filtered | |
# * +filters+ - +Array+ of objects containing the following keys for comparison | |
# <tt>:hash_key</tt> - Dot-notated, nestable hash key | |
# <tt>:comparison_operator</tt> - Any of the following comparison operators: +==+, +!=+, +<=+, +>=+, +<+, +>+ | |
# <tt>:value</tt> - Desired value to compare for | |
def self.valid_hash?(hash, filters = []) | |
hash = hash.with_indifferent_access | |
filters.all? do |filter| | |
# No false filters allowed (everything must pass) | |
raise InvalidFilter.new("Invalid comparison operator. All filters must use one of the following operators: ==, !=, <=, >=, <, >") unless filter[:comparison_operator].in? ['==', '!=', '<=', '>=', '<', '>'] | |
raise InvalidFilter.new("Invalid hash key. All hash keys must consist of only a-z, A-z, 0-9, -, _ characters") unless /^[a-zA-Z0-9\-_\.]+$/ === filter[:hash_key] | |
hash_value = HashFilter.fetch_value(filter[:hash_key], hash) | |
if hash_value.kind_of?(String) && !HashFilter.integer?(hash_value) | |
raise InvalidFilter.new("Invalid comparison operator. You cannot use <=, >=, <, or > on a non-numerical String value") unless filter[:comparison_operator].in? ['==', '!='] | |
end | |
if hash_value.kind_of? Integer | |
hash_value.send(filter[:comparison_operator].to_sym, filter[:value].to_i) | |
else # String | |
hash_value.send(filter[:comparison_operator].to_sym, filter[:value]) | |
end | |
end | |
end | |
# Uses dot-notation to fetch a nested Hash key's value. Returns +nil+ if the key doesn't exist | |
# | |
# ==== Attributes | |
# | |
# * +dot_key+ - Nested hash key lookup i.e. person.address.zip_code | |
def self.fetch_value(dot_key, hash) | |
hash_path = dot_key.split('.') | |
hash_path.reduce(hash) do |memo, key| | |
memo[key.to_s] if memo | |
end | |
end | |
private | |
def self.integer?(string) | |
begin | |
!!Integer(string) | |
rescue ArgumentError, TypeError | |
false | |
end | |
end | |
class InvalidFilter < StandardError; end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require 'spec_helper' | |
# Todo: Handle arrays, booleans, floats, dates | |
describe HashFilter do | |
before :each do | |
@hash = { | |
"person" => { | |
"name" => { | |
"first_name" => "John", | |
"last_name" => "Doe" | |
}, | |
"age" => 21 | |
}, | |
"address" => "1234 Imaginationland", | |
"amount" => "740" | |
} | |
end | |
describe "valid_hash?" do | |
context "hash key type" do | |
before :each do | |
@passing_filter = [ | |
{ | |
hash_key: 'address', | |
comparison_operator: '==', | |
value: '1234 Imaginationland' | |
} | |
] | |
@failing_filter = [ | |
{ | |
hash_key: 'address', | |
comparison_operator: '!=', | |
value: '1234 Imaginationland' | |
} | |
] | |
end | |
it "accepts hashes with string keys" do | |
expect(HashFilter.valid_hash?(@hash, @passing_filter)).to be_true | |
expect(HashFilter.valid_hash?(@hash, @failing_filter)).to be_false | |
end | |
it "accepts hashes with symbol keys" do | |
hash_with_symbols = { | |
person: { | |
name: { | |
first_name: "John", | |
last_name: "Doe" | |
}, | |
age: 21 | |
}, | |
address: "1234 Imaginationland" | |
} | |
expect(HashFilter.valid_hash?(hash_with_symbols, @passing_filter)).to be_true | |
expect(HashFilter.valid_hash?(hash_with_symbols, @failing_filter)).to be_false | |
end | |
end | |
describe "number of passing/failing filters" do | |
it "validates with no filters" do | |
filters = [] | |
expect(HashFilter.valid_hash?(@hash, filters)).to be_true | |
end | |
it "validates with only 1 passing filter" do | |
filters = [ | |
{ | |
hash_key: 'address', | |
comparison_operator: '==', | |
value: '1234 Imaginationland' | |
} | |
] | |
expect(HashFilter.valid_hash?(@hash, filters)).to be_true | |
end | |
it "validates with only 1 failing filter" do | |
filters = [ | |
{ | |
hash_key: 'address', | |
comparison_operator: '!=', | |
value: '1234 Imaginationland' | |
} | |
] | |
expect(HashFilter.valid_hash?(@hash, filters)).to be_false | |
end | |
it "validates with one failing of many filters" do | |
filters = [ | |
{ | |
hash_key: 'address', | |
comparison_operator: '!=', | |
value: '1234 Imaginationland' | |
}, | |
{ | |
hash_key: 'person.age', | |
comparison_operator: '==', | |
value: '21' | |
} | |
] | |
expect(HashFilter.valid_hash?(@hash, filters)).to be_false | |
end | |
it "validates with multiple failing filters" do | |
filters = [ | |
{ | |
hash_key: 'address', | |
comparison_operator: '!=', | |
value: '1234 Imaginationland' | |
}, | |
{ | |
hash_key: 'person.age', | |
comparison_operator: '>', | |
value: '21' | |
}, | |
{ | |
hash_key: 'person.name.first_name', | |
comparison_operator: '!=', | |
value: 'Sarah' | |
} | |
] | |
expect(HashFilter.valid_hash?(@hash, filters)).to be_false | |
end | |
it "validates with all failing filters" do | |
filters = [ | |
{ | |
hash_key: 'address', | |
comparison_operator: '!=', | |
value: '1234 Imaginationland' | |
}, | |
{ | |
hash_key: 'person.age', | |
comparison_operator: '>', | |
value: '21' | |
} | |
] | |
expect(HashFilter.valid_hash?(@hash, filters)).to be_false | |
end | |
it "validates with all passing filters" do | |
filters = [ | |
{ | |
hash_key: 'address', | |
comparison_operator: '==', | |
value: '1234 Imaginationland' | |
}, | |
{ | |
hash_key: 'person.age', | |
comparison_operator: '>=', | |
value: '21' | |
} | |
] | |
expect(HashFilter.valid_hash?(@hash, filters)).to be_true | |
end | |
end | |
describe "hash keys" do | |
it "rejects empty keys" do | |
bad_hash1 = {"" => "John"} | |
bad_hash2 = {nil => "John"} | |
filters1 = [ | |
{ | |
hash_key: '', | |
comparison_operator: '==', | |
value: 'John' | |
} | |
] | |
filters2 = [ | |
{ | |
hash_key: nil, | |
comparison_operator: '==', | |
value: 'John' | |
} | |
] | |
expect{HashFilter.valid_hash?(bad_hash1, filters1)}.to raise_error{HashFilter::InvalidFilter.new} | |
expect{HashFilter.valid_hash?(bad_hash2, filters2)}.to raise_error{HashFilter::InvalidFilter.new} | |
end | |
it "rejects non-[a-zA-Z0-9.-_]" do | |
bad_hash = {"person" => "John"} | |
filters = [ | |
{ | |
hash_key: 'pers*on', | |
comparison_operator: '==', | |
value: 'John' | |
} | |
] | |
expect{HashFilter.valid_hash?(bad_hash, filters)}.to raise_error{HashFilter::InvalidFilter.new} | |
end | |
end | |
describe "comparison operators" do | |
context "strings" do | |
context "number in string" do | |
it "accepts ==" do | |
filters1 = [{hash_key: 'amount', comparison_operator: '==', value: '740'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_true | |
filters2 = [{hash_key: 'amount', comparison_operator: '==', value: '800'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_false | |
end | |
it "accepts !=" do | |
filters1 = [{hash_key: 'amount', comparison_operator: '!=', value: '740'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_false | |
filters2 = [{hash_key: 'amount', comparison_operator: '!=', value: '800'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_true | |
end | |
it "accepts <=" do | |
filters1 = [{hash_key: 'amount', comparison_operator: '<=', value: '740'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_true | |
filters2 = [{hash_key: 'amount', comparison_operator: '<=', value: '800'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_true | |
filters3 = [{hash_key: 'amount', comparison_operator: '<=', value: '200'}] | |
expect(HashFilter.valid_hash?(@hash, filters3)).to be_false | |
end | |
it "accepts >=" do | |
filters1 = [{hash_key: 'amount', comparison_operator: '>=', value: '740'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_true | |
filters2 = [{hash_key: 'amount', comparison_operator: '>=', value: '500'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_true | |
filters3 = [{hash_key: 'amount', comparison_operator: '>=', value: '800'}] | |
expect(HashFilter.valid_hash?(@hash, filters3)).to be_false | |
end | |
it "accepts <" do | |
filters1 = [{hash_key: 'amount', comparison_operator: '<', value: '800'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_true | |
filters2 = [{hash_key: 'amount', comparison_operator: '<', value: '0'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_false | |
end | |
it "accepts >" do | |
filters1 = [{hash_key: 'amount', comparison_operator: '>', value: '800'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_false | |
filters2 = [{hash_key: 'amount', comparison_operator: '>', value: '0'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_true | |
end | |
end | |
context "non-number in string" do | |
it "accepts ==" do | |
filters1 = [{hash_key: 'address', comparison_operator: '==', value: '1234 Imaginationland'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_true | |
filters2 = [{hash_key: 'address', comparison_operator: '==', value: '5678 Dream Avenue'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_false | |
end | |
it "accepts !=" do | |
filters1 = [{hash_key: 'address', comparison_operator: '!=', value: '1234 Imaginationland'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_false | |
filters2 = [{hash_key: 'address', comparison_operator: '!=', value: '5678 Dream Avenue'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_true | |
end | |
it "rejects <=" do | |
filters = [{hash_key: 'address', comparison_operator: '<=', value: '56'}] | |
expect{HashFilter.valid_hash?(@hash, filters)}.to raise_error{HashFilter::InvalidFilter.new} | |
end | |
it "rejects >=" do | |
filters = [{hash_key: 'address', comparison_operator: '>=', value: '56'}] | |
expect{HashFilter.valid_hash?(@hash, filters)}.to raise_error{HashFilter::InvalidFilter.new} | |
end | |
it "rejects <" do | |
filters = [{hash_key: 'address', comparison_operator: '<', value: '56'}] | |
expect{HashFilter.valid_hash?(@hash, filters)}.to raise_error{HashFilter::InvalidFilter.new} | |
end | |
it "rejects >" do | |
filters = [{hash_key: 'address', comparison_operator: '>', value: '56'}] | |
expect{HashFilter.valid_hash?(@hash, filters)}.to raise_error{HashFilter::InvalidFilter.new} | |
end | |
end | |
it "rejects ===" do | |
filters = [{hash_key: 'address', comparison_operator: '===', value: '987 South Park Road'}] | |
expect{HashFilter.valid_hash?(@hash, filters)}.to raise_error{HashFilter::InvalidFilter.new} | |
end | |
it "rejects random non-sense like 'less than'" do | |
filters = [{hash_key: 'address', comparison_operator: 'less than', value: '987 South Park Road'}] | |
expect{HashFilter.valid_hash?(@hash, filters)}.to raise_error{HashFilter::InvalidFilter.new} | |
end | |
end | |
context "integers" do | |
it "accepts ==" do | |
filters1 = [{hash_key: 'person.age', comparison_operator: '==', value: '21'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_true | |
filters2 = [{hash_key: 'person.age', comparison_operator: '==', value: '25'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_false | |
end | |
it "accepts !=" do | |
filters1 = [{hash_key: 'person.age', comparison_operator: '!=', value: '21'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_false | |
filters2 = [{hash_key: 'person.age', comparison_operator: '!=', value: '25'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_true | |
end | |
it "accepts <=" do | |
filters1 = [{hash_key: 'person.age', comparison_operator: '<=', value: '21'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_true | |
filters2 = [{hash_key: 'person.age', comparison_operator: '<=', value: '25'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_true | |
filters3 = [{hash_key: 'person.age', comparison_operator: '<=', value: '17'}] | |
expect(HashFilter.valid_hash?(@hash, filters3)).to be_false | |
end | |
it "accepts >=" do | |
filters1 = [{hash_key: 'person.age', comparison_operator: '<=', value: '21'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_true | |
filters2 = [{hash_key: 'person.age', comparison_operator: '<=', value: '25'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_true | |
filters3 = [{hash_key: 'person.age', comparison_operator: '<=', value: '17'}] | |
expect(HashFilter.valid_hash?(@hash, filters3)).to be_false | |
end | |
it "accepts <" do | |
filters1 = [{hash_key: 'person.age', comparison_operator: '<', value: '25'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_true | |
filters2 = [{hash_key: 'person.age', comparison_operator: '<', value: '0'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_false | |
end | |
it "accepts >" do | |
filters1 = [{hash_key: 'person.age', comparison_operator: '>', value: '25'}] | |
expect(HashFilter.valid_hash?(@hash, filters1)).to be_false | |
filters2 = [{hash_key: 'person.age', comparison_operator: '>', value: '0'}] | |
expect(HashFilter.valid_hash?(@hash, filters2)).to be_true | |
end | |
it "rejects ===" do | |
filters = [{hash_key: 'person.age', comparison_operator: '===', value: '25'}] | |
expect{HashFilter.valid_hash?(@hash, filters)}.to raise_error{HashFilter::InvalidFilter.new} | |
end | |
it "rejects random non-sense like 'less than'" do | |
filters = [{hash_key: 'person.age', comparison_operator: 'less than', value: '25'}] | |
expect{HashFilter.valid_hash?(@hash, filters)}.to raise_error{HashFilter::InvalidFilter.new} | |
end | |
end | |
end | |
describe "values" do | |
it "compares integers" do | |
passing_integer_filter = [{ | |
hash_key: 'person.age', | |
comparison_operator: '>', | |
value: 20 | |
}] | |
failing_integer_filter = [{ | |
hash_key: 'person.age', | |
comparison_operator: '<=', | |
value: 12 | |
}] | |
expect(HashFilter.valid_hash?(@hash, passing_integer_filter)).to be_true | |
expect(HashFilter.valid_hash?(@hash, failing_integer_filter)).to be_false | |
end | |
it "compares numbers in strings" do | |
passing_integer_filter = [{ | |
hash_key: 'person.age', | |
comparison_operator: '>', | |
value: '20' | |
}] | |
failing_integer_filter = [{ | |
hash_key: 'person.age', | |
comparison_operator: '<=', | |
value: '12' | |
}] | |
expect(HashFilter.valid_hash?(@hash, passing_integer_filter)).to be_true | |
expect(HashFilter.valid_hash?(@hash, failing_integer_filter)).to be_false | |
end | |
it "compares strings" do | |
passing_string_filter = [{ | |
hash_key: 'address', | |
comparison_operator: '==', | |
value: '1234 Imaginationland' | |
}] | |
failing_string_filter = [{ | |
hash_key: 'address', | |
comparison_operator: '!=', | |
value: '1234 Imaginationland' | |
}] | |
expect(HashFilter.valid_hash?(@hash, passing_string_filter)).to be_true | |
expect(HashFilter.valid_hash?(@hash, failing_string_filter)).to be_false | |
end | |
end | |
end | |
describe "fetch_value(dot_key, hash)" do | |
context "key exists" do | |
it "fetches top-level key values" do | |
expect(HashFilter.fetch_value("address", @hash)).to eql "1234 Imaginationland" | |
end | |
it "fetches second-level key values" do | |
expect(HashFilter.fetch_value("person.age", @hash)).to eql 21 | |
end | |
it "fetches third-level key values" do | |
expect(HashFilter.fetch_value("person.name.first_name", @hash)).to eql "John" | |
end | |
end | |
context "key doesn't exist" do | |
it "returns nil for top-level keys" do | |
expect(HashFilter.fetch_value("dog", @hash)).to eql nil | |
end | |
it "returns nil for deeply-nested keys" do | |
expect(HashFilter.fetch_value("person.name.middle_name", @hash)).to eql nil | |
end | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment