modelish 0.1.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.
@@ -0,0 +1,8 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ .yardoc/*
6
+ doc/*
7
+ *~
8
+ .DS_Store
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --colour
data/.rvmrc ADDED
@@ -0,0 +1,3 @@
1
+ branch_name=`git branch --no-color 2> /dev/null | sed -e '/^[^*]/d' -e 's/* \(.*\)/\1/'`
2
+ #rvm --create use 1.9.2@modelish-${branch_name}
3
+ rvm --create use 1.8.7@modelish-${branch_name}
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in modelish.gemspec
4
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (C) 2011 by Maeve Revels
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
@@ -0,0 +1,95 @@
1
+ # modelish #
2
+
3
+ When a real modeling framework is too heavy, sometimes you want something just
4
+ a little modelish.
5
+
6
+ If a Hash or OpenStruct almost suits your needs, but you need bits of
7
+ model-like behavior such as simple validation and typed values, modelish can
8
+ help.
9
+
10
+ If you need persistence or anything but the most basic functionality, modelish
11
+ will frustrate you to no end.
12
+
13
+ ## Installation ##
14
+
15
+ For the especially foolhardy, you can:
16
+
17
+ 1. Add this to your Gemfile:
18
+
19
+ gem 'modelish', :git => 'git://github.com/maeve/modelish.git'
20
+
21
+ 2. Execute `bundle install`
22
+
23
+ ## Basics ##
24
+
25
+ The modelish syntax is very similar to some of the classes provided by
26
+ [hashie]. In fact, the initial implementation simply extended
27
+ [Hashie::Trash][trash] to add property types:
28
+
29
+ require 'modelish'
30
+
31
+ class Foo < Modelish::Base
32
+ property :my_date, :type => Date
33
+ property :my_float, :type => Float, :default => 0.0
34
+ property :my_funky_id, :type => Integer, :from => 'MyFUNKYId'
35
+ property :my_simple_property
36
+ end
37
+
38
+ You can then set properties as string value and have them automatically
39
+ converted to the appropriate type, while respecting default values and
40
+ key-mappings:
41
+
42
+ f = Foo.new('MyFUNKYId' => '42',
43
+ :my_date => '2011-03-10',
44
+ :my_simple_property => 'bar')
45
+
46
+ f.my_date
47
+ => #<Date: 2011-03-10 (4911261/2,0,2299161)>
48
+ f.my_float
49
+ => 0.0
50
+ f.my_funky_id
51
+ => 42
52
+ f.my_simple_property
53
+ => "bar"
54
+
55
+ modelish also supports defining simple property validations:
56
+
57
+ class Bar < Modelish::Base
58
+ property :important_field, :required => true
59
+ property :state, :max_length => 2
60
+ property :my_int, :type => Integer, :validate_type => true
61
+ property :another_field, :validator => lambda { |val| "val must respond to []" unless val.respond_to?(:[]) }
62
+ end
63
+
64
+ Validations can be run using methods that return an error map (keyed on property name), raise errors, or return a boolean value to indicate validation outcome.
65
+
66
+ valid_bar = Bar.new(:important_field => 'some value',
67
+ :state => 'OR',
68
+ :my_int => 42,
69
+ :another_field => Hash.new)
70
+ valid_bar.valid?
71
+ => true
72
+
73
+ valid_bar.validate
74
+ => {}
75
+
76
+ valid_bar.validate!
77
+ => nil
78
+
79
+
80
+ invalid_bar = Bar.new(:state => 'a value that is too long',
81
+ :my_int => 'this is not an integer',
82
+ :another_field => Object.new)
83
+ invalid_bar.valid?
84
+ => false
85
+
86
+ invalid_bar.validate
87
+ => {:important_field=>[#<ArgumentError: important_field must not be nil or blank>], :my_int=>[#<ArgumentError: my_int must be of type Integer, but got "this is not an integer">], :another_field=>[#<ArgumentError: val must respond to []>], :state=>[#<ArgumentError: state must be less than 2 characters>]}
88
+
89
+ invalid_bar.validate!
90
+ ArgumentError: important_field must not be nil or blank
91
+ from /Users/maeverevels/projects/modelish/lib/modelish/validations.rb:31:in `validate!'
92
+ ...
93
+
94
+ [hashie]: https://github.com/intridea/hashie
95
+ [trash]: http://rdoc.info/github/intridea/hashie/master/Hashie/Trash
@@ -0,0 +1,17 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rspec/core/rake_task'
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task :default => :spec
8
+
9
+ namespace :doc do
10
+ require 'yard'
11
+ YARD::Rake::YardocTask.new do |task|
12
+ task.files = ['README.md', 'lib/**/*.rb']
13
+ task.options = [
14
+ '--markup', 'markdown',
15
+ ]
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ $:.unshift(File.expand_path('modelish',File.dirname(__FILE__)))
2
+
3
+ require 'version'
4
+ require 'base'
5
+
6
+ module Modelish
7
+ # Your code goes here...
8
+ end
@@ -0,0 +1,44 @@
1
+ require 'hashie'
2
+ require 'property_types'
3
+ require 'validations'
4
+
5
+ module Modelish
6
+ class Base < Hashie::Trash
7
+ include PropertyTypes
8
+ include Validations
9
+
10
+ # Creates a new attribute.
11
+ #
12
+ # @param [Symbol] name the name of the property
13
+ # @param [Hash] options configuration for the property
14
+ # @option opts [Object] :default the default value for this property
15
+ # when the value has not been explicitly
16
+ # set (defaults to nil)
17
+ # @option opts [#to_s] :from the original key name for this attribute
18
+ # (created as write-only)
19
+ # @option opts [Class,Proc] :type the type of the property value. For
20
+ # a list of accepted types, see
21
+ # {Modelish::PropertyTypes}
22
+ # @options opts [true,false] :required enables validation for the property
23
+ # value's presence; nil or blank values
24
+ # will cause validation methods to fail
25
+ # @options opts [Integer] :max_length the maximum allowable length for a valid
26
+ # property value
27
+ # @options opts [true,false] :validate_type enables validation for the property value's
28
+ # type based on the :type option
29
+ # @options opts [Proc] :validator A block that accepts a value and validates it;
30
+ # should return nil if validation passes, or an error
31
+ # message or error object if validation fails.
32
+ # See {Modelish::Validations}
33
+ def self.property(name, options={})
34
+ super
35
+
36
+ add_property_type(name, options[:type]) if options[:type]
37
+
38
+ add_validator(name) { |val| validate_required(name => val).first } if options[:required]
39
+ add_validator(name) { |val| validate_length(name, val, options[:max_length]) } if options[:max_length]
40
+ add_validator(name, &options[:validator]) if options[:validator]
41
+ add_validator(name) { |val| validate_type(name, val, options[:type]) } if options[:validate_type]
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,133 @@
1
+ require 'date'
2
+
3
+ module Modelish
4
+ # Mixes in behavior for automatically converting property types.
5
+ module PropertyTypes
6
+ def self.included(base)
7
+ base.extend(ClassMethods)
8
+ end
9
+
10
+ module ClassMethods
11
+ # Adds a typed property to the model.
12
+ # This dynamically generates accessor/mutator methods that perform
13
+ # the appropriate type conversions on the property's value.
14
+ #
15
+ # Generated methods:
16
+ # * +<property_name>=(new_value)+ -- sets the property value.
17
+ # * +<property_name>+ -- returns the property value, converted to the configured
18
+ # type. If the value cannot be converted, no error will be
19
+ # raised, and the raw unconverted value will be returned.
20
+ # * +<property_name>!+ -- returns the property value, converted to the configured
21
+ # type. If the value cannot be converted, a TypeError will
22
+ # be raised.
23
+ # * +raw_<property_name> -- the original property value, without any type conversions.
24
+ #
25
+ # @param [Symbol] property_name the name of the property.
26
+ # @param [Class, Proc] property_type the type of the property's value.
27
+ # Valid types include:
28
+ # * +Integer+
29
+ # * +Float+
30
+ # * +Array+
31
+ # * +Date+ -- converts using Date.parse on value.to_s
32
+ # * +Symbol+ -- also converts from camel case to snake case
33
+ # * +String+
34
+ # * any arbitrary +Class+ -- will attempt conversion by passing the raw
35
+ # value into the class's initializer
36
+ # * an instance of +Proc+ -- will convert the value by executing the proc,
37
+ # passing in the raw value as an argument
38
+ def add_property_type(property_name, property_type=String)
39
+ accessor = property_name.to_sym
40
+ property_types[accessor] = property_type
41
+
42
+ raw_accessor = define_raw_accessor(accessor)
43
+ bang_accessor = define_bang_accessor(accessor)
44
+
45
+ typed_accessor = "_typed_#{accessor}".to_sym
46
+ typed_mutator = "#{typed_accessor}=".to_sym
47
+ to_safe = "_to_safe_#{accessor}".to_sym
48
+
49
+ class_eval do
50
+ attr_accessor typed_accessor
51
+ private typed_accessor, typed_mutator
52
+
53
+ define_method(to_safe) do
54
+ self.send(bang_accessor) rescue self.send(raw_accessor)
55
+ end
56
+ private to_safe
57
+
58
+ define_method(accessor) do
59
+ val = self.send(typed_accessor)
60
+
61
+ unless val || self.send(raw_accessor).nil?
62
+ val = self.send(to_safe)
63
+ self.send(typed_mutator, val)
64
+ end
65
+
66
+ val
67
+ end
68
+
69
+ define_method("#{accessor}=") do |val|
70
+ self.send("#{raw_accessor}=", val)
71
+ self.send(typed_mutator, self.send(to_safe))
72
+ end
73
+ end
74
+ end
75
+
76
+ def property_types
77
+ @property_types ||= {}
78
+ end
79
+
80
+ private
81
+ def define_raw_accessor(name)
82
+ accessor = name.to_sym
83
+ raw_accessor = "raw_#{name}".to_sym
84
+
85
+ mutator = "#{name}=".to_sym
86
+ raw_mutator = "raw_#{name}=".to_sym
87
+
88
+ class_eval do
89
+ if method_defined?(accessor) && method_defined?(mutator)
90
+ alias_method(raw_accessor, accessor)
91
+ alias_method(raw_mutator, mutator)
92
+ else
93
+ attr_accessor raw_accessor
94
+ end
95
+ end
96
+
97
+ raw_accessor
98
+ end
99
+
100
+ def define_bang_accessor(property_name)
101
+ bang_accessor = "#{property_name}!".to_sym
102
+ converter = value_converter(property_types[property_name.to_sym])
103
+
104
+ class_eval do
105
+ define_method(bang_accessor) do
106
+ value = self.send("raw_#{property_name}")
107
+ (converter && value) ? converter.call(value) : value
108
+ end
109
+ end
110
+
111
+ bang_accessor
112
+ end
113
+
114
+ def value_converter(property_type)
115
+ if property_type == Date
116
+ lambda { |val| Date.parse(val.to_s) }
117
+ elsif property_type == Symbol
118
+ lambda { |val| val.to_s.strip.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2').
119
+ gsub(/([a-z\d])([A-Z])/, '\\1_\\2').
120
+ gsub(/\s+|-/,'_').downcase.to_sym }
121
+ elsif property_type == String
122
+ lambda { |val| val.to_s.strip }
123
+ elsif Kernel.respond_to?(property_type.to_s)
124
+ lambda { |val| Kernel.send(property_type.to_s, val) }
125
+ elsif property_type.respond_to?(:call)
126
+ property_type
127
+ else
128
+ lambda { |val| property_type.new(val) }
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,208 @@
1
+ require 'date'
2
+
3
+ module Modelish
4
+ module Validations
5
+ def self.included(base)
6
+ base.extend(ClassMethods)
7
+ end
8
+
9
+ # Validates all properties based on configured validators.
10
+ #
11
+ # @return [Hash<Symbol,Array>] map of errors where key is the property name
12
+ # and value is the list of errors
13
+ # @see ClassMethods#add_validator
14
+ def validate
15
+ errors = {}
16
+
17
+ call_validators do |name,message|
18
+ errors[name] ||= []
19
+ errors[name] << to_error(message)
20
+ end
21
+
22
+ errors
23
+ end
24
+
25
+ # Validates all properties based on configured validators.
26
+ #
27
+ # @raise ArgumentError when any property fails validation
28
+ def validate!
29
+ call_validators do |name,message|
30
+ error = to_error(message)
31
+ raise error if error
32
+ end
33
+ nil
34
+ end
35
+
36
+ def valid?
37
+ validate.empty?
38
+ end
39
+
40
+ private
41
+ def call_validators(&error_handler)
42
+ self.class.validators.each_pair do |k,v|
43
+ v.each do |prop_validator|
44
+ if msg = prop_validator.call(self.send(k))
45
+ error_handler.call(k, msg)
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ def to_error(msg)
52
+ msg.is_a?(StandardError) ? msg : ArgumentError.new(msg.to_s)
53
+ end
54
+
55
+ module ClassMethods
56
+ # Sets up a block containing validation logic for a given property.
57
+ # Each property may have 0 or more validators.
58
+ #
59
+ # @param [String,Symbol] property_name the name of the property to validate
60
+ # @param [#call] validator the block containing the validation logic; must return
61
+ # either an error object or a string containing the error
62
+ # message if validation fails.
63
+ #
64
+ # @example adding a validator that only allows non-nil values
65
+ # class MyModel
66
+ # include Modelish::Validations
67
+ # attr_accessor :foo
68
+ # add_validator(:foo) { |val| val.nil? ? 'foo must not be nil': nil }
69
+ # end
70
+ def add_validator(property_name, &validator)
71
+ validators[property_name.to_sym] ||= []
72
+ validators[property_name.to_sym] << validator
73
+
74
+ class_eval do
75
+ attr_accessor property_name.to_sym unless method_defined?(property_name.to_sym)
76
+ end
77
+ end
78
+
79
+ # A map of registered validator blocks, keyed on property_name.
80
+ def validators
81
+ @validators ||= {}
82
+ end
83
+
84
+ # Validates the required values, returning a list of errors when validation
85
+ # fails.
86
+ #
87
+ # @param [Hash] args the map of name => value pairs to validate
88
+ # @return [Array<ArgumentError>] a list of ArgumentErrors for validation failures.
89
+ def validate_required(args)
90
+ errors = []
91
+ args.each do |name, value|
92
+ errors << ArgumentError.new("#{name} must not be nil or blank") if value.nil? || value.to_s.strip.empty?
93
+ end
94
+ errors
95
+ end
96
+
97
+ # Validates the required values, raising an error when validation fails.
98
+ #
99
+ # @param [Hash] args the map of name => value pairs to validate
100
+ # @raise [ArgumentError] when any value in args hash is nil or empty. The name
101
+ # key will be used to construct an informative error message.
102
+ def validate_required!(args)
103
+ errors = validate_required(args)
104
+ raise errors.first unless errors.empty?
105
+ end
106
+
107
+ # Validates the required values, returning a boolean indicating validation success.
108
+ #
109
+ # @param [Hash] args the map of name => value pairs to validate
110
+ # @return [true,false] true when validation passes; false when validation fails
111
+ def validate_required?(args)
112
+ validate_required(args).empty?
113
+ end
114
+
115
+ # Validates the length of a value, raising an error to indicate validation failure.
116
+ #
117
+ # @param (see .validate_length)
118
+ # @raise [ArgumentError] when the value is longer than max_length
119
+ def validate_length!(name, value, max_length)
120
+ error = validate_length(name, value, max_length)
121
+ raise error if error
122
+ end
123
+
124
+ # Validates the length of a value, returning a boolean to indicate validation
125
+ # success.
126
+ #
127
+ # @param (see .validate_length)
128
+ # @return [true,false] true if value does not exceed max_length; false otherwise
129
+ def validate_length?(name, value, max_length)
130
+ validate_length(name, value, max_length).nil?
131
+ end
132
+
133
+ # Validates the length of a value, returning an error when validation fails.
134
+ #
135
+ # @param [Symbol,String] name the name of the property/argument to be validated
136
+ # @param [#length] value the value to be validated
137
+ # @param [#to_i] max_length the maximum allowable length
138
+ # @raise [ArgumentError] when the value is longer than max_length
139
+ def validate_length(name, value, max_length)
140
+ if max_length.to_i > 0 && value.to_s.length > max_length.to_i
141
+ ArgumentError.new("#{name} must be less than #{max_length} characters")
142
+ end
143
+ end
144
+
145
+ # Validates the type of the value, returning a boolean indicating validation
146
+ # outcome.
147
+ #
148
+ # @see #validate_type
149
+ # @param {see #validate_type}
150
+ # @return [true,false] true when the value is the correct type; false otherwise
151
+ def validate_type?(name, value, type)
152
+ validate_type(name, value, type).nil?
153
+ end
154
+
155
+ # Validates the type of the value, raising an error when the value is not
156
+ # of the correct type.
157
+ #
158
+ # @see #validate_type
159
+ # @param {see #validate_type}
160
+ # @raise [ArgumentError] when the value is not the correct type
161
+ def validate_type!(name, value, type)
162
+ error = validate_type(name, value, type)
163
+ raise error if error
164
+ end
165
+
166
+ # Validates the type of the value, returning an error when the value cannot
167
+ # be converted to the appropriate type.
168
+ #
169
+ # @param [Symbol,String] name the name of the property/argument to be validated
170
+ # @param [Object] value the value to be validated
171
+ # @param [Class,Proc] type the type of the class to be validated. Supported types include:
172
+ # * +Integer+
173
+ # * +Float+
174
+ # * +Date+
175
+ # * +Symbol+
176
+ # * any arbitrary +Class+ -- validates based on the results of is_a?
177
+ # @return [ArgumentError] when validation fails
178
+ def validate_type(name, value, type)
179
+ error = nil
180
+
181
+ begin
182
+ if value && type
183
+ # Can't use a case statement because of the way === is implemented on some classes
184
+ if type == Integer
185
+ is_valid = (value.is_a?(Integer) || value.to_s =~ /^\-?\d+$/)
186
+ elsif type == Float
187
+ is_valid = (value.is_a?(Float) || value.to_s =~ /^\-?\d+\.?\d*$/)
188
+ elsif type == Date
189
+ is_valid = (value.is_a?(Date) || Date.parse(value))
190
+ elsif type == Symbol
191
+ is_valid = value.respond_to?(:to_sym)
192
+ else
193
+ is_valid = value.is_a?(type)
194
+ end
195
+
196
+ unless is_valid
197
+ error = ArgumentError.new("#{name} must be of type #{type}, but got #{value.inspect}")
198
+ end
199
+ end
200
+ rescue StandardError => e
201
+ error = ArgumentError.new("An error occurred validating #{name} with value #{value.inspect}: #{e.message}")
202
+ end
203
+
204
+ error
205
+ end
206
+ end
207
+ end
208
+ end