model_attribute 0.0.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,15 +1,15 @@
1
1
  ---
2
2
  !binary "U0hBMQ==":
3
3
  metadata.gz: !binary |-
4
- NjdhYmE2MDhjYmNiY2JhZWVmYzdjZTJmYjNiZjQ4YzJlNjM5MzlmMQ==
4
+ ZjY2MDhkZDI2ZjA0YjRjM2NkY2E5MTg0NWYxZmJhY2JmZTEyYWUzYg==
5
5
  data.tar.gz: !binary |-
6
- ZTUyNGM5MGZjNzNjODMxZGZkNmQ0YjQxNzAzY2RiOWU5M2NmOTc4ZA==
6
+ MWRhZjRhOTUzNDQ5MzRhMWU5NTQwOTdkMmJjYmJlMDA1OTJhMzFiMA==
7
7
  SHA512:
8
8
  metadata.gz: !binary |-
9
- ODdlNmY2ZDlhNzdkNmQ3Mjg5MjZiNGFkMDg5ZWYwNWJhOWFlMmZkNTE4NWVj
10
- ZWJmZTk1MGY0ZjBkNDMzZmIzZTM1MjQyMzI2ODI1ZDc4ZmYxNzhiYjZkN2Rm
11
- YTZiNWI0OTNlYzg2ZmU3YTA1ZDg3ZjM4Yzk5MWMxYTA0NjAxOTM=
9
+ ZjZmMjY3NzMzM2EyODAyMDM1MTA2NTgxNGRhOWJhNGNmMGJmZTA4NDVmMzJi
10
+ NWQ0MzEyYTFlZDNjZmI5N2I5Y2FjMDE3NWE0MDIyMDBlNWJmMzNkNzNmMjBh
11
+ YTQ2Y2MzMjU2ZGI4MzNhODE5NDJkMjEyMmZmNjU3NDUwYjAzNTI=
12
12
  data.tar.gz: !binary |-
13
- NGU1NWU5YWUzMzc4YjI0ZGQ4M2QwNjFkMGM0ZDg3NGNmZjE1YzdjYTU1NTdh
14
- NTNlNjZlMzhhMzRjYjE2NThkZmY1MWMyNGU2ZGRmNWNmNzhkMzZjZjg5N2Uw
15
- NDgwZjdlNzFhNGRhZTE5NjczYzU2ODg2NDYzZDU3OWQwNmYyYzU=
13
+ ODg1MWVhYjI3N2ZmNDM0MWY1ZGE1NWVmYWM3YzI4ODdiZTEzMGY0MDQ5YjRh
14
+ YjA3MWNmMjlmYTJmNTU4ZjJjNzk2ZmUwOWRjZjhhMGJkYTUzNGZlZDZmZGNh
15
+ N2VhZGJjYWE1MzgxYjY4ZDQwNWI4YmE5ZGY0OWNjZjk4YmNjMGI=
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,34 @@
1
+ # Change Log
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## 2.0.0
6
+
7
+ - **Breaking change**: Rename to `ModelAttribute` (no trailing 's') to avoid name
8
+ clash with another gem.
9
+
10
+ ## 1.4.0
11
+
12
+ - **New method**: #changes_for_json Returns a hash from attribute name to its
13
+ new value, suitable for serialization to a JSON string. Easily generate the
14
+ payload to send in an HTTP PUT to a web service.
15
+
16
+ - **New attribute type: json** Store an array/hash/etc. built using the basic
17
+ JSON data types: nil, numeric, string, boolean, hash and array.
18
+
19
+ ## 1.3.0
20
+
21
+ - **Breaking change**: Parsing an integer to a time attribute, the integer is
22
+ treated as the number of milliseconds since the epoch (not the number of
23
+ seconds). `attributes_as_json` emits integers for time attributes.
24
+
25
+ ## 1.2.0
26
+
27
+ - **Breaking change**: `attributes_as_json` removed; replaced with
28
+ `attributes_for_json`. You will have to serialize this yourself:
29
+ `Oj.dump(attributes_for_json, mode: :strict)`. This allows you to modify the
30
+ returned hash before serializing it.
31
+
32
+ ## 1.1.0
33
+
34
+ - Initial release
data/Gemfile CHANGED
@@ -1,4 +1,5 @@
1
1
  source 'https://rubygems.org'
2
+ source 'http://gems.int.yammer.com/'
2
3
 
3
4
  # Specify your gem's dependencies in model_attribute.gemspec
4
5
  gemspec
data/Guardfile ADDED
@@ -0,0 +1,47 @@
1
+ # A sample Guardfile
2
+ # More info at https://github.com/guard/guard#readme
3
+
4
+ ## Uncomment and set this to only include directories you want to watch
5
+ # directories %w(app lib config test spec feature)
6
+
7
+ ## Uncomment to clear the screen before every task
8
+ # clearing :on
9
+
10
+ ## Make Guard exit when config is changed so it can be restarted
11
+ #
12
+ ## Note: if you want Guard to automatically start up again, run guard in a
13
+ ## shell loop, e.g.:
14
+ #
15
+ # $ while bundle exec guard; do echo "Restarting Guard..."; done
16
+ #
17
+ ## Note: if you are using the `directories` clause above and you are not
18
+ ## watching the project directory ('.'), the you will want to move the Guardfile
19
+ ## to a watched dir and symlink it back, e.g.
20
+ #
21
+ # $ mkdir config
22
+ # $ mv Guardfile config/
23
+ # $ ln -s config/Guardfile .
24
+ #
25
+ # and, you'll have to watch "config/Guardfile" instead of "Guardfile"
26
+ #
27
+ watch ("Guardfile") do
28
+ UI.info "Exiting because Guard must be restarted for changes to take effect"
29
+ exit 0
30
+ end
31
+
32
+ guard :rspec, cmd: "bundle exec rspec --format=Nc --format=documentation" do
33
+ require "guard/rspec/dsl"
34
+ dsl = Guard::RSpec::Dsl.new(self)
35
+
36
+ # RSpec files
37
+ rspec = dsl.rspec
38
+ watch(rspec.spec_helper) { rspec.spec_dir }
39
+ watch(rspec.spec_support) { rspec.spec_dir }
40
+ watch(rspec.spec_files)
41
+
42
+ # Ruby files
43
+ ruby = dsl.ruby
44
+ dsl.watch_spec_files_for(ruby.lib_files)
45
+
46
+ watch(%r{lib/*}) { 'spec' }
47
+ end
data/LICENSE.txt CHANGED
@@ -1,4 +1,8 @@
1
- Copyright (c) 2015 David Waller
1
+ ModelAttribute
2
+
3
+ Copyright (c) 2015 Microsoft Corporation
4
+
5
+ All rights reserved.
2
6
 
3
7
  MIT License
4
8
 
data/README.md CHANGED
@@ -1,6 +1,170 @@
1
1
  # ModelAttribute
2
2
 
3
- Placeholder while clearing gem release with legal.
3
+ Simple attributes for a non-ActiveRecord model.
4
+
5
+ - Stores attributes in instance variables.
6
+ - Type casting and checking.
7
+ - Dirty tracking.
8
+ - List attribute names and values.
9
+ - Handles integers, booleans, strings and times - a set of types that are very
10
+ easy to persist to and parse from JSON.
11
+ - Supports efficient serialization of attributes to JSON.
12
+ - Mass assignment - handy for initializers.
13
+
14
+ Why not [Virtus][virtus-gem]? Virtus doesn't provide attribute tracking, and
15
+ doesn't integrate with [ActiveModel::Dirty][am-dirty]. So if you're not using
16
+ ActiveRecord, but you need attributes with dirty tracking, ModelAttribute may be
17
+ what you're after. For example, it works very well for a model that fronts an
18
+ HTTP web service, and you want dirty tracking so you can PATCH appropriately.
19
+
20
+ Also in favor of ModelAttribute:
21
+
22
+ - It's simple - less than [200 lines of code][source].
23
+ - It supports efficient serialization and deserialization to/from JSON.
24
+
25
+ [virtus-gem]:https://github.com/solnic/virtus
26
+ [am-dirty]:https://github.com/rails/rails/blob/v3.0.20/activemodel/lib/active_model/dirty.rb
27
+ [source]:https://github.com/yammer/model_attribute/blob/master/lib/model_attribute.rb
28
+
29
+ ## Usage
30
+
31
+ ```ruby
32
+ require 'model_attribute'
33
+ class User
34
+ extend ModelAttribute
35
+ attribute :id, :integer
36
+ attribute :paid, :boolean
37
+ attribute :name, :string
38
+ attribute :created_at, :time
39
+ attribute :grades, :json
40
+
41
+ def initialize(attributes = {})
42
+ set_attributes(attributes)
43
+ end
44
+ end
45
+
46
+ User.attributes # => [:id, :paid, :name, :created_at, :grades]
47
+ user = User.new
48
+
49
+ user.attributes # => {:id=>nil, :paid=>nil, :name=>nil, :created_at=>nil, :grades=>nil}
50
+
51
+ # An integer attribute
52
+ user.id # => nil
53
+
54
+ user.id = 3
55
+ user.id # => 3
56
+
57
+ # Stores values that convert cleanly to an integer
58
+ user.id = '5'
59
+ user.id # => 5
60
+
61
+ # Protects you against nonsense assignment
62
+ user.id = '5error'
63
+ ArgumentError: invalid value for Integer(): "5error"
64
+
65
+ # A boolean attribute
66
+ user.paid # => nil
67
+ user.paid = true
68
+
69
+ # Booleans also define a predicate method (ending in '?')
70
+ user.paid? # => true
71
+
72
+ # Conversion from strings used by databases.
73
+ user.paid = 'f'
74
+ user.paid # => false
75
+ user.paid = 't'
76
+ user.paid # => true
77
+
78
+ # A :time attribute
79
+ user.created_at = Time.now
80
+ user.created_at # => 2015-01-08 15:57:05 +0000
81
+
82
+ # Also converts from other reasonable time formats
83
+ user.created_at = "2014-12-25 14:00:00 +0100"
84
+ user.created_at # => 2014-12-25 13:00:00 +0000
85
+ user.created_at = Date.parse('2014-01-08')
86
+ user.created_at # => 2014-01-08 00:00:00 +0000
87
+ user.created_at = DateTime.parse("2014-12-25 13:00:45")
88
+ user.created_at # => 2014-12-25 13:00:45 +0000
89
+ # Convert from seconds since the epoch
90
+ user.created_at = Time.now.to_f
91
+ user.created_at # => 2015-01-08 16:23:02 +0000
92
+ # Or milliseconds since the epoch
93
+ user.created_at = 1420734182000
94
+ user.created_at # => 2015-01-08 16:23:02 +0000
95
+
96
+ # A :json attribute is schemaless and accepts the basic JSON types - hash,
97
+ # array, nil, numeric, string and boolean.
98
+ user.grades = {'maths' => 'A', 'history' => 'C'}
99
+ user.grades # => {"maths"=>"A", "history"=>"C"}
100
+ user.grades = ['A', 'A*', 'C']
101
+ user.grades # => ["A", "A*", "C"]
102
+ user.grades = 'AAB'
103
+ user.grades # => "AAB"
104
+ user.grades = Time.now
105
+ # => RuntimeError: JSON only supports nil, numeric, string, boolean and arrays and hashes of those.
106
+
107
+ # read_attribute and write_attribute methods
108
+ user.read_attribute(:created_at)
109
+ user.write_attribute(:name, 'Fred')
110
+
111
+ # View attributes
112
+ user.attributes # => {:id=>5, :paid=>true, :name=>"Fred", :created_at=>2015-01-08 15:57:05 +0000, :grades=>{"maths"=>"A", "history"=>"C"}}
113
+ user.inspect # => "#<User id: 5, paid: true, name: \"Fred\", created_at: 2015-01-08 15:57:05 +0000, grades: {\"maths\"=>\"A\", \"history\"=>\"C\"}>"
114
+
115
+ # Mass assignment
116
+ user.set_attributes(name: "Sally", paid: false)
117
+ user.attributes # => {:id=>5, :paid=>false, :name=>"Sally", :created_at=>2015-01-08 15:57:05 +0000}
118
+
119
+ # Efficient JSON serialization and deserialization.
120
+ # Attributes with nil values are omitted.
121
+ user.attributes_for_json
122
+ # => {"id"=>5, "paid"=>true, "name"=>"Fred", "created_at"=>1421171317762}
123
+ require 'oj'
124
+ Oj.dump(user.attributes_for_json, mode: :strict)
125
+ # => "{\"id\":5,\"paid\":true,\"name\":\"Fred\",\"created_at\":1421171317762}"
126
+ user2 = User.new(Oj.load(json, strict: true))
127
+
128
+ # Change tracking. A much smaller set of function than that provided by
129
+ # ActiveModel::Dity.
130
+ user.changes # => {:id=>[nil, 5], :paid=>[nil, true], :created_at=>[nil, 2015-01-08 15:57:05 +0000], :name=>[nil, "Fred"]}
131
+ user.name_changed? # => true
132
+ # If you need the new values to send as a PUT to a web service
133
+ user.changes_for_json # => {"id"=>5, "paid"=>true, "name"=>"Fred", "created_at"=>1421171317762}
134
+ # If you're imitating ActiveRecord behaviour, changes are cleared after
135
+ # after_save callbacks, but before after_commit callbacks.
136
+ user.changes.clear
137
+ user.changes # => {}
138
+
139
+ # Equality of all the attribute values match
140
+ another = User.new
141
+ another.id = 5
142
+ another.paid = true
143
+ another.created_at = user.created_at
144
+ another.name = 'Fred'
145
+
146
+ user == another # => true
147
+ user === another # => true
148
+ user.eql? another # => true
149
+
150
+ # Making some attributes private
151
+
152
+ class User
153
+ extend ModelAttribute
154
+ attribute :events, :string
155
+ private :events=
156
+
157
+ def initialize(attributes)
158
+ # Pass flag to set_attributes to allow setting attributes with private writers
159
+ set_attributes(attributes, true)
160
+ end
161
+
162
+ def add_event(new_event)
163
+ events ||= ""
164
+ events += new_event
165
+ end
166
+ end
167
+ ```
4
168
 
5
169
  ## Installation
6
170
 
@@ -18,10 +182,6 @@ Or install it yourself as:
18
182
 
19
183
  $ gem install model_attribute
20
184
 
21
- ## Usage
22
-
23
- TODO: Write usage instructions here
24
-
25
185
  ## Contributing
26
186
 
27
187
  1. Fork it ( https://github.com/[my-github-username]/model_attribute/fork )
data/Rakefile CHANGED
@@ -1,2 +1 @@
1
1
  require "bundler/gem_tasks"
2
-
@@ -1,5 +1,202 @@
1
1
  require "model_attribute/version"
2
+ require "model_attribute/json"
3
+ require "model_attribute/errors"
4
+ require "time"
2
5
 
3
6
  module ModelAttribute
4
- # Your code goes here...
7
+ SUPPORTED_TYPES = [:integer, :boolean, :string, :time, :json]
8
+
9
+ def self.extended(base)
10
+ base.send(:include, InstanceMethods)
11
+ base.instance_variable_set('@attribute_names', [])
12
+ base.instance_variable_set('@attribute_types', {})
13
+ end
14
+
15
+ def attribute(name, type)
16
+ name = name.to_sym
17
+ type = type.to_sym
18
+ raise UnsupportedTypeError.new(type) unless SUPPORTED_TYPES.include?(type)
19
+
20
+ @attribute_names << name
21
+ @attribute_types[name] = type
22
+
23
+ self.class_eval(<<-CODE, __FILE__, __LINE__ + 1)
24
+ def #{name}=(value)
25
+ write_attribute(#{name.inspect}, value, #{type.inspect})
26
+ end
27
+
28
+ def #{name}
29
+ read_attribute(#{name.inspect})
30
+ end
31
+
32
+ def #{name}_changed?
33
+ !!changes[#{name.inspect}]
34
+ end
35
+ CODE
36
+
37
+ if type == :boolean
38
+ self.class_eval(<<-CODE, __FILE__, __LINE__ + 1)
39
+ def #{name}?
40
+ !!read_attribute(#{name.inspect})
41
+ end
42
+ CODE
43
+ end
44
+ end
45
+
46
+ def attributes
47
+ @attribute_names
48
+ end
49
+
50
+ module InstanceMethods
51
+ def write_attribute(name, value, type = nil)
52
+ name = name.to_sym
53
+
54
+ # Don't want to expose attribute types as a method on the class, so access
55
+ # via a back door.
56
+ type ||= self.class.instance_variable_get('@attribute_types')[name]
57
+ raise InvalidAttributeNameError.new(name) unless type
58
+
59
+ value = cast(value, type)
60
+ return if value == read_attribute(name)
61
+
62
+ if changes.has_key? name
63
+ original = changes[name].first
64
+ else
65
+ original = read_attribute(name)
66
+ end
67
+
68
+ if original == value
69
+ changes.delete(name)
70
+ else
71
+ changes[name] = [original, value]
72
+ end
73
+
74
+ instance_variable_set("@#{name}", value)
75
+ end
76
+
77
+ def read_attribute(name)
78
+ ivar_name = "@#{name}"
79
+ if instance_variable_defined?(ivar_name)
80
+ instance_variable_get(ivar_name)
81
+ elsif !self.class.attributes.include?(name.to_sym)
82
+ raise InvalidAttributeNameError.new(name)
83
+ end
84
+ end
85
+
86
+ def attributes
87
+ self.class.attributes.each_with_object({}) do |name, attributes|
88
+ attributes[name] = read_attribute(name)
89
+ end
90
+ end
91
+
92
+ def set_attributes(attributes, can_set_private_attrs = false)
93
+ attributes.each do |key, value|
94
+ send("#{key}=", value) if respond_to?("#{key}=", can_set_private_attrs)
95
+ end
96
+ end
97
+
98
+ def ==(other)
99
+ return true if equal?(other)
100
+ if respond_to?(:id)
101
+ other.kind_of?(self.class) && id == other.id
102
+ else
103
+ other.kind_of?(self.class) && attributes == other.attributes
104
+ end
105
+ end
106
+ alias_method :eql?, :==
107
+
108
+ def changes
109
+ @changes ||= {} #HashWithIndifferentAccess.new
110
+ end
111
+
112
+ # Attributes suitable for serializing to a JSON string.
113
+ #
114
+ # - Attribute keys are strings (for 'strict' JSON dumping).
115
+ # - Attributes with a nil value are omitted to speed serialization.
116
+ # - :time attributes are serialized as an Integer giving the number of
117
+ # milliseconds since the epoch.
118
+ def attributes_for_json
119
+ self.class.attributes.each_with_object({}) do |name, attributes|
120
+ value = read_attribute(name)
121
+ unless value.nil?
122
+ value = (value.to_f * 1000).to_i if value.is_a? Time
123
+ attributes[name.to_s] = value
124
+ end
125
+ end
126
+ end
127
+
128
+ # Changed attributes suitable for serializing to a JSON string. Returns a
129
+ # hash from attribute name (as a string) to the new value of that attribute,
130
+ # for attributes that have changed.
131
+ #
132
+ # - :time attributes are serialized as an Integer giving the number of
133
+ # milliseconds since the epoch.
134
+ # - Unlike attributes_for_json, attributes that have changed to a nil value
135
+ # *are* included.
136
+ def changes_for_json
137
+ hash = {}
138
+ changes.each do |attr_name, (_old_value, new_value)|
139
+ new_value = (new_value.to_f * 1000).to_i if new_value.is_a? Time
140
+ hash[attr_name.to_s] = new_value
141
+ end
142
+
143
+ hash
144
+ end
145
+
146
+ # Includes the class name and all the attributes and their values. e.g.
147
+ # "#<User id: 1, paid: true, name: \"Fred\", created_at: 2014-12-25 08:00:00 +0000>"
148
+ def inspect
149
+ attribute_string = self.class.attributes.map do |key|
150
+ "#{key}: #{read_attribute(key).inspect}"
151
+ end.join(', ')
152
+ "#<#{self.class} #{attribute_string}>"
153
+ end
154
+
155
+ def cast(value, type)
156
+ return nil if value.nil?
157
+
158
+ case type
159
+ when :integer
160
+ int = Integer(value)
161
+ float = Float(value)
162
+ raise "Can't cast #{value.inspect} to an integer without loss of precision" unless int == float
163
+ int
164
+ when :boolean
165
+ if !!value == value
166
+ value
167
+ elsif value == 't'
168
+ true
169
+ elsif value == 'f'
170
+ false
171
+ else
172
+ raise "Can't cast #{value.inspect} to boolean"
173
+ end
174
+ when :time
175
+ case value
176
+ when Time
177
+ value
178
+ when Date, DateTime
179
+ value.to_time
180
+ when Integer
181
+ # Assume milliseconds since epoch.
182
+ Time.at(value / 1000.0)
183
+ when Numeric
184
+ # Numeric, but not an integer. Assume seconds since epoch.
185
+ Time.at(value)
186
+ else
187
+ Time.parse(value)
188
+ end
189
+ when :string
190
+ String(value)
191
+ when :json
192
+ if Json.valid?(value)
193
+ value
194
+ else
195
+ raise "JSON only supports nil, numeric, string, boolean and arrays and hashes of those."
196
+ end
197
+ else
198
+ raise UnsupportedTypeError.new(type)
199
+ end
200
+ end
201
+ end
5
202
  end