model_attribute 0.0.1 → 2.0.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 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