hashcast 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +8 -0
- data/.travis.yml +3 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +50 -0
- data/LICENSE.txt +22 -0
- data/README.md +125 -0
- data/Rakefile +1 -0
- data/benchmark/benchmark.rb +66 -0
- data/benchmark/casters.rb +50 -0
- data/docs/_config.yml +1 -0
- data/docs/index.md +124 -0
- data/hcast.gemspec +27 -0
- data/lib/hashcast.rb +46 -0
- data/lib/hashcast/attributes_caster.rb +97 -0
- data/lib/hashcast/attributes_parser.rb +63 -0
- data/lib/hashcast/caster.rb +144 -0
- data/lib/hashcast/casters.rb +13 -0
- data/lib/hashcast/casters/array_caster.rb +27 -0
- data/lib/hashcast/casters/boolean_caster.rb +13 -0
- data/lib/hashcast/casters/date_caster.rb +15 -0
- data/lib/hashcast/casters/datetime_caster.rb +15 -0
- data/lib/hashcast/casters/float_caster.rb +14 -0
- data/lib/hashcast/casters/hash_caster.rb +8 -0
- data/lib/hashcast/casters/integer_caster.rb +15 -0
- data/lib/hashcast/casters/string_caster.rb +8 -0
- data/lib/hashcast/casters/symbol_caster.rb +15 -0
- data/lib/hashcast/casters/time_caster.rb +15 -0
- data/lib/hashcast/concern.rb +136 -0
- data/lib/hashcast/config.rb +11 -0
- data/lib/hashcast/errors.rb +49 -0
- data/lib/hashcast/metadata/attribute.rb +30 -0
- data/lib/hashcast/version.rb +3 -0
- data/spec/hcast/caster_spec.rb +471 -0
- data/spec/hcast/casters_spec.rb +245 -0
- data/spec/hcast/hcast_spec.rb +37 -0
- data/spec/spec_helper.rb +21 -0
- metadata +169 -0
data/hcast.gemspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'hashcast/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "hashcast"
|
8
|
+
spec.version = HashCast::VERSION
|
9
|
+
spec.authors = ["Albert Gazizov", "Roman Heinrich"]
|
10
|
+
spec.email = ["deeper4k@gmail.com"]
|
11
|
+
spec.description = %q{Declarative Hash Caster}
|
12
|
+
spec.summary = %q{Declarative Hash Caster}
|
13
|
+
spec.homepage = "http://github.com/ddd-ruby/hashcast"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split($/)
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(spec)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
22
|
+
spec.add_development_dependency "byebug", '~> 0'
|
23
|
+
spec.add_development_dependency "rake", '~> 0'
|
24
|
+
spec.add_development_dependency "bixby-bench", '~> 0'
|
25
|
+
spec.add_development_dependency "allocation_stats", '~> 0'
|
26
|
+
spec.add_development_dependency "codecov", '~> 0'
|
27
|
+
end
|
data/lib/hashcast.rb
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'hashcast/version'
|
2
|
+
require 'hashcast/errors'
|
3
|
+
require 'hashcast/config'
|
4
|
+
require 'hashcast/casters'
|
5
|
+
require 'hashcast/concern'
|
6
|
+
require 'hashcast/metadata/attribute'
|
7
|
+
require 'hashcast/attributes_parser'
|
8
|
+
require 'hashcast/attributes_caster'
|
9
|
+
require 'hashcast/caster'
|
10
|
+
|
11
|
+
module HashCast
|
12
|
+
@@casters = {}
|
13
|
+
|
14
|
+
# Defines caster without adding own class
|
15
|
+
# @note Not yet implemented
|
16
|
+
def self.create(&block)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Returns list of defined casters
|
20
|
+
def self.casters
|
21
|
+
@@casters
|
22
|
+
end
|
23
|
+
|
24
|
+
# Adds new casters to HashCast
|
25
|
+
# Allow extend HashCast with your own casters
|
26
|
+
# @param caster_name [Symbol] caster name
|
27
|
+
# @param caster [Class] caster
|
28
|
+
def self.add_caster(caster_name, caster)
|
29
|
+
@@casters[caster_name] = caster
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.config
|
33
|
+
@@config ||= HashCast::Config.new
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
HashCast.add_caster(:array, HashCast::Casters::ArrayCaster)
|
38
|
+
HashCast.add_caster(:boolean, HashCast::Casters::BooleanCaster)
|
39
|
+
HashCast.add_caster(:date, HashCast::Casters::DateCaster)
|
40
|
+
HashCast.add_caster(:datetime, HashCast::Casters::DateTimeCaster)
|
41
|
+
HashCast.add_caster(:float, HashCast::Casters::FloatCaster)
|
42
|
+
HashCast.add_caster(:hash, HashCast::Casters::HashCast)
|
43
|
+
HashCast.add_caster(:integer, HashCast::Casters::IntegerCaster)
|
44
|
+
HashCast.add_caster(:string, HashCast::Casters::StringCaster)
|
45
|
+
HashCast.add_caster(:symbol, HashCast::Casters::SymbolCaster)
|
46
|
+
HashCast.add_caster(:time, HashCast::Casters::TimeCaster)
|
@@ -0,0 +1,97 @@
|
|
1
|
+
class HashCast::AttributesCaster
|
2
|
+
attr_reader :attributes, :options
|
3
|
+
|
4
|
+
def initialize(attributes, options)
|
5
|
+
@attributes = attributes
|
6
|
+
@options = options
|
7
|
+
end
|
8
|
+
|
9
|
+
def cast(input_hash)
|
10
|
+
casted_hash = {}
|
11
|
+
|
12
|
+
hash_keys = get_keys(input_hash)
|
13
|
+
attributes.each do |attribute|
|
14
|
+
if hash_keys.include?(attribute.name)
|
15
|
+
begin
|
16
|
+
casted_value = cast_attribute(attribute, input_hash)
|
17
|
+
casted_hash[cast_key(attribute.name, options)] = casted_value
|
18
|
+
rescue HashCast::Errors::AttributeError => e
|
19
|
+
e.add_namespace(attribute.name)
|
20
|
+
raise e
|
21
|
+
end
|
22
|
+
else
|
23
|
+
raise HashCast::Errors::MissingAttributeError.new("should be given", attribute.name) if attribute.required?
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
if !options[:skip_unexpected_attributes]
|
28
|
+
check_unexpected_attributes_not_given!(hash_keys, casted_hash.keys)
|
29
|
+
end
|
30
|
+
|
31
|
+
casted_hash
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def cast_attribute(attribute, hash)
|
37
|
+
value = get_value(hash, attribute.name)
|
38
|
+
return nil if value.nil? && attribute.allow_nil?
|
39
|
+
|
40
|
+
casted_value = attribute.caster.cast(value, attribute.name, attribute.options)
|
41
|
+
|
42
|
+
if attribute.has_children?
|
43
|
+
return cast_children(casted_value, attribute)
|
44
|
+
end
|
45
|
+
if caster = attribute.options[:caster]
|
46
|
+
return cast_children_with_caster(casted_value, attribute, caster)
|
47
|
+
end
|
48
|
+
|
49
|
+
casted_value
|
50
|
+
end
|
51
|
+
|
52
|
+
def cast_children(value, attribute)
|
53
|
+
caster = self.class.new(attribute.children, options)
|
54
|
+
cast_children_with_caster(value, attribute, caster)
|
55
|
+
end
|
56
|
+
|
57
|
+
def cast_children_with_caster(value, attribute, caster)
|
58
|
+
return caster.cast(value) if attribute.caster != HashCast::Casters::ArrayCaster
|
59
|
+
|
60
|
+
value.map do |val|
|
61
|
+
caster.cast(val)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def cast_key(value, options)
|
66
|
+
return value if options[:output_keys] == :symbol
|
67
|
+
return value.to_s if options[:output_keys] == :string
|
68
|
+
end
|
69
|
+
|
70
|
+
def get_keys(hash)
|
71
|
+
return hash.keys if same_in_out_key_format?
|
72
|
+
hash.keys.map(&:to_sym)
|
73
|
+
end
|
74
|
+
|
75
|
+
def get_value(hash, key)
|
76
|
+
return hash[key] if same_in_out_key_format?
|
77
|
+
return hash[key.to_sym] if options[:input_keys] == :symbol
|
78
|
+
hash[key.to_s]
|
79
|
+
end
|
80
|
+
|
81
|
+
def check_unexpected_attributes_not_given!(input_hash_keys, casted_hash_keys)
|
82
|
+
unexpected_keys = keys_diff(input_hash_keys, casted_hash_keys)
|
83
|
+
unless unexpected_keys.empty?
|
84
|
+
raise HashCast::Errors::UnexpectedAttributeError.new("is not valid attribute name", unexpected_keys.first)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def same_in_out_key_format?
|
89
|
+
options[:input_keys] == options[:output_keys]
|
90
|
+
end
|
91
|
+
|
92
|
+
def keys_diff(input_hash_keys, casted_hash_keys)
|
93
|
+
return (input_hash_keys - casted_hash_keys) if same_in_out_key_format?
|
94
|
+
return (input_hash_keys - casted_hash_keys) if options[:output_keys] == :symbol # same for symbol
|
95
|
+
return (input_hash_keys - casted_hash_keys.map(&:to_sym)) if options[:output_keys] == :string
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
# Parses caster rules
|
2
|
+
# and returns list of HashCast::Metadata::Attribute instances
|
3
|
+
# which contains casting rules
|
4
|
+
class HashCast::AttributesParser
|
5
|
+
|
6
|
+
# Performs casting
|
7
|
+
# @param block [Proc] block with casting rules
|
8
|
+
# @return Array(HashCast::Metadata::Attribute) list of casting rules
|
9
|
+
def self.parse(&block)
|
10
|
+
dsl = DSL.new
|
11
|
+
dsl.instance_exec(&block)
|
12
|
+
dsl.attributes
|
13
|
+
end
|
14
|
+
|
15
|
+
class DSL
|
16
|
+
attr_reader :attributes
|
17
|
+
|
18
|
+
def initialize
|
19
|
+
@attributes = []
|
20
|
+
end
|
21
|
+
|
22
|
+
# Redefined becase each class has the built in hash method
|
23
|
+
def hash(*args, &block)
|
24
|
+
method_missing(:hash, *args, &block)
|
25
|
+
end
|
26
|
+
|
27
|
+
def method_missing(caster_name, *args, &block)
|
28
|
+
attr_name = args[0]
|
29
|
+
options = args[1] || {}
|
30
|
+
caster = HashCast.casters[caster_name]
|
31
|
+
|
32
|
+
check_caster_exists!(caster, caster_name)
|
33
|
+
check_attr_name_valid!(attr_name)
|
34
|
+
check_options_is_hash!(options)
|
35
|
+
|
36
|
+
attribute = HashCast::Metadata::Attribute.new(attr_name, caster, options)
|
37
|
+
if block_given?
|
38
|
+
attribute.children = HashCast::AttributesParser.parse(&block)
|
39
|
+
end
|
40
|
+
attributes << attribute
|
41
|
+
end
|
42
|
+
|
43
|
+
private
|
44
|
+
|
45
|
+
def check_caster_exists!(caster, caster_name)
|
46
|
+
if !caster
|
47
|
+
raise HashCast::Errors::CasterNotFoundError, "caster with name '#{caster_name}' is not found"
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def check_attr_name_valid!(attr_name)
|
52
|
+
if !attr_name.is_a?(Symbol) && !attr_name.is_a?(String)
|
53
|
+
raise HashCast::Errors::ArgumentError, "attribute name should be a symbol or string"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def check_options_is_hash!(options)
|
58
|
+
if !options.is_a?(Hash)
|
59
|
+
raise HashCast::Errors::ArgumentError, "attribute options should be a Hash"
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
@@ -0,0 +1,144 @@
|
|
1
|
+
# Include this module to create your caster
|
2
|
+
#
|
3
|
+
# Example caster:
|
4
|
+
# class ContactCaster
|
5
|
+
# include HashCast::Caster
|
6
|
+
#
|
7
|
+
# attributes do
|
8
|
+
# hash :contact do
|
9
|
+
# string :name
|
10
|
+
# integer :age, optional: true
|
11
|
+
# float :weight
|
12
|
+
# date :birthday
|
13
|
+
# datetime :last_logged_in
|
14
|
+
# time :last_visited_at
|
15
|
+
# hash :company do
|
16
|
+
# string :name
|
17
|
+
# end
|
18
|
+
# array :emails, each: :string
|
19
|
+
# array :social_accounts, each: :hash do
|
20
|
+
# string :name
|
21
|
+
# symbol :type
|
22
|
+
# end
|
23
|
+
# end
|
24
|
+
# end
|
25
|
+
# end
|
26
|
+
#
|
27
|
+
# The defined caster will have #cast method which accepts hash
|
28
|
+
# Use it to cast hash:
|
29
|
+
# ContactCaster.new.cast({
|
30
|
+
# contact: {
|
31
|
+
# name: "John Smith",
|
32
|
+
# age: "22",
|
33
|
+
# weight: "65.5",
|
34
|
+
# birthday: "2014-02-02",
|
35
|
+
# last_logged_in: "2014-02-02 10:10:00",
|
36
|
+
# last_visited_at: "2014-02-02 10:10:00",
|
37
|
+
# company: {
|
38
|
+
# name: "MyCo",
|
39
|
+
# },
|
40
|
+
# emails: [ "test@example.com", "test2@example.com" ],
|
41
|
+
# social_accounts: [
|
42
|
+
# {
|
43
|
+
# name: "john_smith",
|
44
|
+
# type: 'twitter',
|
45
|
+
# },
|
46
|
+
# {
|
47
|
+
# name: "John",
|
48
|
+
# type: :facebook,
|
49
|
+
# },
|
50
|
+
# ]
|
51
|
+
# }
|
52
|
+
# })
|
53
|
+
#
|
54
|
+
# The output will be casted hash:
|
55
|
+
# {
|
56
|
+
# contact: {
|
57
|
+
# name: "John Smith",
|
58
|
+
# age: 22,
|
59
|
+
# weight: 65.5,
|
60
|
+
# birthday: Date.parse("2014-02-02"),
|
61
|
+
# last_logged_in: DateTime.parse("2014-02-02 10:10:00"),
|
62
|
+
# last_visited_at: Time.parse("2014-02-02 10:10:00"),
|
63
|
+
# company: {
|
64
|
+
# name: "MyCo",
|
65
|
+
# },
|
66
|
+
# emails: [ "test@example.com", "test2@example.com" ],
|
67
|
+
# social_accounts: [
|
68
|
+
# {
|
69
|
+
# name: "john_smith",
|
70
|
+
# type: :twitter,
|
71
|
+
# },
|
72
|
+
# {
|
73
|
+
# name: "John",
|
74
|
+
# type: :facebook,
|
75
|
+
# },
|
76
|
+
# ]
|
77
|
+
# }
|
78
|
+
# }
|
79
|
+
module HashCast::Caster
|
80
|
+
extend HashCast::Concern
|
81
|
+
|
82
|
+
module ClassMethods
|
83
|
+
ALLOWED_OPTIONS = [:string, :symbol]
|
84
|
+
|
85
|
+
# Defines casting rules
|
86
|
+
# @example
|
87
|
+
# attributes do
|
88
|
+
# string :first_name
|
89
|
+
# string :last_name
|
90
|
+
# integer :age, optional: true
|
91
|
+
# end
|
92
|
+
def attributes(&block)
|
93
|
+
raise ArgumentError, "You should provide block" unless block_given?
|
94
|
+
|
95
|
+
attributes = HashCast::AttributesParser.parse(&block)
|
96
|
+
self.instance_variable_set(:@attributes, attributes)
|
97
|
+
end
|
98
|
+
|
99
|
+
# Performs casting
|
100
|
+
# @param hash [Hash] hash for casting
|
101
|
+
# @param options [Hash] options, input_keys: :string, output_key: :symbol
|
102
|
+
def cast(hash, options = {})
|
103
|
+
check_attributes_defined!
|
104
|
+
check_hash_given!(hash)
|
105
|
+
check_options!(options)
|
106
|
+
options = set_default_options(options)
|
107
|
+
|
108
|
+
attributes_caster = HashCast::AttributesCaster.new(instance_variable_get(:@attributes), options)
|
109
|
+
attributes_caster.cast(hash)
|
110
|
+
end
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def check_attributes_defined!
|
115
|
+
unless instance_variable_defined?(:@attributes)
|
116
|
+
raise HashCast::Errors::ArgumentError, "Attributes block should be defined"
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def check_options!(options)
|
121
|
+
unless options.is_a?(Hash)
|
122
|
+
raise HashCast::Errors::ArgumentError, "Options should be a hash"
|
123
|
+
end
|
124
|
+
if options[:input_keys] && !ALLOWED_OPTIONS.include?(options[:input_keys])
|
125
|
+
raise HashCast::Errors::ArgumentError, "input_keys should be :string or :symbol"
|
126
|
+
end
|
127
|
+
if options[:output_keys] && !ALLOWED_OPTIONS.include?(options[:output_keys])
|
128
|
+
raise HashCast::Errors::ArgumentError, "output_keys should be :string or :symbol"
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def check_hash_given!(hash)
|
133
|
+
unless hash.is_a?(Hash)
|
134
|
+
raise HashCast::Errors::ArgumentError, "Hash should be given"
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def set_default_options(options)
|
139
|
+
options[:input_keys] ||= HashCast.config.input_keys
|
140
|
+
options[:output_keys] ||= HashCast.config.output_keys
|
141
|
+
options
|
142
|
+
end
|
143
|
+
end
|
144
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
# List of build in casters
|
2
|
+
module HashCast::Casters
|
3
|
+
require 'hashcast/casters/array_caster'
|
4
|
+
require 'hashcast/casters/boolean_caster'
|
5
|
+
require 'hashcast/casters/date_caster'
|
6
|
+
require 'hashcast/casters/datetime_caster'
|
7
|
+
require 'hashcast/casters/float_caster'
|
8
|
+
require 'hashcast/casters/hash_caster'
|
9
|
+
require 'hashcast/casters/integer_caster'
|
10
|
+
require 'hashcast/casters/string_caster'
|
11
|
+
require 'hashcast/casters/symbol_caster'
|
12
|
+
require 'hashcast/casters/time_caster'
|
13
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
class HashCast::Casters::ArrayCaster
|
2
|
+
|
3
|
+
def self.cast(value, attr_name, options = {})
|
4
|
+
raise HashCast::Errors::CastingError, "should be an array" unless value.is_a?(Array)
|
5
|
+
return value unless options[:each]
|
6
|
+
|
7
|
+
cast_array_items(value, attr_name, options)
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def self.cast_array_items(array, attr_name, options)
|
13
|
+
caster_name = options[:each]
|
14
|
+
caster = HashCast.casters[caster_name]
|
15
|
+
check_caster_exists!(caster, caster_name)
|
16
|
+
array.map do |item|
|
17
|
+
caster.cast(item, "#{attr_name} item", options)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.check_caster_exists!(caster, caster_name)
|
22
|
+
unless caster
|
23
|
+
raise HashCast::Errors::CasterNotFoundError, "caster with name #{caster_name} is not found"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
class HashCast::Casters::BooleanCaster
|
2
|
+
REAL_BOOLEANS = [TrueClass, FalseClass]
|
3
|
+
TRUE_VALUES = ['1', 'true', 'on', 1]
|
4
|
+
FALSE_VALUES = ['0', 'false', 'off', 0]
|
5
|
+
|
6
|
+
def self.cast(value, attr_name, options = {})
|
7
|
+
return value if REAL_BOOLEANS.include?(value.class)
|
8
|
+
return true if TRUE_VALUES.include?(value)
|
9
|
+
return false if FALSE_VALUES.include?(value)
|
10
|
+
raise HashCast::Errors::CastingError, "should be a boolean"
|
11
|
+
end
|
12
|
+
|
13
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
class HashCast::Casters::DateCaster
|
2
|
+
|
3
|
+
def self.cast(value, attr_name, options = {})
|
4
|
+
return value if value.is_a?(Date)
|
5
|
+
return cast_string(value) if value.is_a?(String)
|
6
|
+
raise HashCast::Errors::CastingError, "should be a date"
|
7
|
+
end
|
8
|
+
|
9
|
+
def self.cast_string(value)
|
10
|
+
Date.parse(value)
|
11
|
+
rescue ArgumentError => e
|
12
|
+
raise HashCast::Errors::CastingError, "is invalid date"
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|