Repository: yammer/model_attribute Branch: master Commit: 4bc9b26bdbe9 Files: 17 Total size: 48.3 KB Directory structure: gitextract__nywioe0/ ├── .gitignore ├── .rspec ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── Guardfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── lib/ │ ├── model_attribute/ │ │ ├── casts.rb │ │ ├── errors.rb │ │ └── version.rb │ └── model_attribute.rb ├── model_attribute.gemspec ├── performance_comparison.rb └── spec/ ├── model_attributes_spec.rb └── spec_helper.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ /.bundle/ /.yardoc /Gemfile.lock /_yardoc/ /coverage/ /doc/ /pkg/ /spec/reports/ /tmp/ *.bundle *.so *.o *.a *.gem mkmf.log ================================================ FILE: .rspec ================================================ --color --require spec_helper --warnings ================================================ FILE: .travis.yml ================================================ language: ruby install: - bundle install --without interactive rvm: - "2.4.10" - "2.5.8" - "2.6.6" - "2.7.1" - "jruby-9.1.17.0" - "jruby-9.2.11.1" ================================================ FILE: CHANGELOG.md ================================================ # Change Log All notable changes to this project will be documented in this file. ## 3.2.0 - Support `Float` type with `:float`. - Tested with Ruby 2.4. ## 3.1.0 - Allow strings 'true' and 'false' to be assigned to boolean attributes and be cast as expected. ## 3.0.0 - **Breaking change**: All casting errors raise `ArgumentError`. Previously some errors during casting would raise `RuntimeError`. Thanks to [@gotascii](https://github.com/gotascii) for the report. ## 2.1.0 - **New feature**: default values. Allows you to specify a default value like so: ``` class User attribute :name, :string, default: 'Michelle' end User.new.name # => 'Michelle' ``` ## 2.0.0 - **Breaking change**: Rename to `ModelAttribute` (no trailing 's') to avoid name clash with another gem. ## 1.4.0 - **New method**: #changes_for_json Returns a hash from attribute name to its new value, suitable for serialization to a JSON string. Easily generate the payload to send in an HTTP PUT to a web service. - **New attribute type: json** Store an array/hash/etc. built using the basic JSON data types: nil, numeric, string, boolean, hash and array. ## 1.3.0 - **Breaking change**: Parsing an integer to a time attribute, the integer is treated as the number of milliseconds since the epoch (not the number of seconds). `attributes_as_json` emits integers for time attributes. ## 1.2.0 - **Breaking change**: `attributes_as_json` removed; replaced with `attributes_for_json`. You will have to serialize this yourself: `Oj.dump(attributes_for_json, mode: :strict)`. This allows you to modify the returned hash before serializing it. ## 1.1.0 - Initial release ================================================ FILE: Gemfile ================================================ source 'https://rubygems.org' # Specify your gem's dependencies in model_attribute.gemspec gemspec group 'interactive' do gem 'rspec-nc' gem 'guard' gem 'guard-rspec' end ================================================ FILE: Guardfile ================================================ # A sample Guardfile # More info at https://github.com/guard/guard#readme ## Uncomment and set this to only include directories you want to watch # directories %w(app lib config test spec feature) ## Uncomment to clear the screen before every task # clearing :on ## Make Guard exit when config is changed so it can be restarted # ## Note: if you want Guard to automatically start up again, run guard in a ## shell loop, e.g.: # # $ while bundle exec guard; do echo "Restarting Guard..."; done # ## Note: if you are using the `directories` clause above and you are not ## watching the project directory ('.'), the you will want to move the Guardfile ## to a watched dir and symlink it back, e.g. # # $ mkdir config # $ mv Guardfile config/ # $ ln -s config/Guardfile . # # and, you'll have to watch "config/Guardfile" instead of "Guardfile" # watch ("Guardfile") do UI.info "Exiting because Guard must be restarted for changes to take effect" exit 0 end guard :rspec, cmd: "bundle exec rspec --format=Nc --format=documentation", all_on_start: true do require "guard/rspec/dsl" dsl = Guard::RSpec::Dsl.new(self) # RSpec files rspec = dsl.rspec watch(rspec.spec_helper) { rspec.spec_dir } watch(rspec.spec_support) { rspec.spec_dir } watch(rspec.spec_files) # Ruby files ruby = dsl.ruby dsl.watch_spec_files_for(ruby.lib_files) watch(%r{lib/*}) { 'spec' } end ================================================ FILE: LICENSE.txt ================================================ ModelAttribute Copyright (c) 2015 Microsoft Corporation All rights reserved. MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # ModelAttribute [![Gem Version](https://badge.fury.io/rb/model_attribute.svg)](http://badge.fury.io/rb/model_attribute) [![Build Status](https://travis-ci.org/yammer/model_attribute.svg?branch=master)](https://travis-ci.org/yammer/model_attribute) Simple attributes for a non-ActiveRecord model. - Stores attributes in instance variables. - Type casting and checking. - Dirty tracking. - List attribute names and values. - Default values for attributes - Handles integers, floats, booleans, strings and times - a set of types that are very easy to persist to and parse from JSON. - Supports efficient serialization of attributes to JSON. - Mass assignment - handy for initializers. Why not [Virtus][virtus-gem]? Virtus doesn't provide dirty tracking, and doesn't integrate with [ActiveModel::Dirty][am-dirty]. So if you're not using ActiveRecord, but you need attributes with dirty tracking, ModelAttribute may be what you're after. For example, it works very well for a model that fronts an HTTP web service, and you want dirty tracking so you can PATCH appropriately. Also in favor of ModelAttribute: - It's simple - less than [200 lines of code][source]. - It supports efficient serialization and deserialization to/from JSON. [virtus-gem]:https://github.com/solnic/virtus [am-dirty]:https://github.com/rails/rails/blob/master/activemodel/lib/active_model/dirty.rb [source]:https://github.com/yammer/model_attribute/blob/master/lib/model_attribute.rb ## Integrating with Rails If you're using ModelAttribute in a Rails application, you will probably want to augment your model with other methods to make it behave more like `ActiveRecord`. `ActiveModel` provides a very useful set of mixins, described in the [Rails guide][active-model-guide]. You can also see an example of the methods we found useful at Yammer described in [this blog post][yammer-blog-post], with full source in [this Gist][active-record-mimic]. [active-model-guide]:https://guides.rubyonrails.org/active_model_basics.html [yammer-blog-post]:https://medium.com/yammer-engineering/activerecord-stole-my-data-and-now-i-want-it-back-3041ac4eb163 [active-record-mimic]:https://gist.github.com/dwaller/5474304cfea354a9701d ## Usage ```ruby require 'model_attribute' class User extend ModelAttribute attribute :id, :integer attribute :paid, :boolean attribute :name, :string attribute :created_at, :time attribute :grades, :json def initialize(attributes = {}) set_attributes(attributes) end end User.attributes # => [:id, :paid, :name, :created_at, :grades] user = User.new user.attributes # => {:id=>nil, :paid=>nil, :name=>nil, :created_at=>nil, :grades=>nil} # An integer attribute user.id # => nil user.id = 3 user.id # => 3 # Stores values that convert cleanly to an integer user.id = '5' user.id # => 5 # Protects you against nonsense assignment user.id = '5error' ArgumentError: invalid value for Integer(): "5error" # A boolean attribute user.paid # => nil user.paid = true # Booleans also define a predicate method (ending in '?') user.paid? # => true # Conversion from strings used by databases. user.paid = 'f' user.paid # => false user.paid = 't' user.paid # => true user.paid = 'false' user.paid # => false user.paid = 'true' user.paid # => true # A :time attribute user.created_at = Time.now user.created_at # => 2015-01-08 15:57:05 +0000 # Also converts from other reasonable time formats user.created_at = "2014-12-25 14:00:00 +0100" user.created_at # => 2014-12-25 13:00:00 +0000 user.created_at = Date.parse('2014-01-08') user.created_at # => 2014-01-08 00:00:00 +0000 user.created_at = DateTime.parse("2014-12-25 13:00:45") user.created_at # => 2014-12-25 13:00:45 +0000 # Convert from seconds since the epoch user.created_at = Time.now.to_f user.created_at # => 2015-01-08 16:23:02 +0000 # Or milliseconds since the epoch user.created_at = 1420734182000 user.created_at # => 2015-01-08 16:23:02 +0000 # A :json attribute is schemaless and accepts the basic JSON types - hash, # array, nil, numeric, string and boolean. user.grades = {'maths' => 'A', 'history' => 'C'} user.grades # => {"maths"=>"A", "history"=>"C"} user.grades = ['A', 'A*', 'C'] user.grades # => ["A", "A*", "C"] user.grades = 'AAB' user.grades # => "AAB" user.grades = Time.now # => ArgumentError: JSON only supports nil, numeric, string, boolean and arrays and hashes of those. # read_attribute and write_attribute methods user.read_attribute(:created_at) user.write_attribute(:name, 'Fred') # View attributes user.attributes # => {:id=>5, :paid=>true, :name=>"Fred", :created_at=>2015-01-08 15:57:05 +0000, :grades=>{"maths"=>"A", "history"=>"C"}} user.inspect # => "#\"A\", \"history\"=>\"C\"}>" # Mass assignment user.set_attributes(name: "Sally", paid: false) user.attributes # => {:id=>5, :paid=>false, :name=>"Sally", :created_at=>2015-01-08 15:57:05 +0000} # Efficient JSON serialization and deserialization. # Attributes with nil values are omitted. user.attributes_for_json # => {"id"=>5, "paid"=>true, "name"=>"Fred", "created_at"=>1421171317762} require 'oj' Oj.dump(user.attributes_for_json, mode: :strict) # => "{\"id\":5,\"paid\":true,\"name\":\"Fred\",\"created_at\":1421171317762}" user2 = User.new(Oj.load(json, strict: true)) # Change tracking. A much smaller set of functions than that provided by # ActiveModel::Dirty. user.changes # => {:id=>[nil, 5], :paid=>[nil, true], :created_at=>[nil, 2015-01-08 15:57:05 +0000], :name=>[nil, "Fred"]} user.name_changed? # => true # If you need the new values to send as a PUT to a web service user.changes_for_json # => {"id"=>5, "paid"=>true, "name"=>"Fred", "created_at"=>1421171317762} # If you're imitating ActiveRecord behaviour, changes are cleared after # after_save callbacks, but before after_commit callbacks. user.changes.clear user.changes # => {} # Equality if all the attribute values match another = User.new another.id = 5 another.paid = true another.created_at = user.created_at another.name = 'Fred' user == another # => true user === another # => true user.eql? another # => true # Making some attributes private class User extend ModelAttribute attribute :events, :string private :events= def initialize(attributes) # Pass flag to set_attributes to allow setting attributes with private writers set_attributes(attributes, true) end def add_event(new_event) events ||= "" events += new_event end end # Supporting default attributes class UserWithDefaults extend ModelAttribute attribute :name, :string, default: 'Charlie' end UserWithDefaults.attribute_defaults # => {:name=>"Charlie"} user = UserWithDefaults.new user.name # => "Charlie" user.read_attribute(:name) # => "Charlie" user.attributes # => {:name=>"Charlie"} # attributes_for_json omits defaults to keep the JSON compact user.attributes_for_json # => {} # You can add them back in if you need them user.attributes_for_json.merge(user.class.attribute_defaults) # => {:name=>"Charlie"} # A default isn't a change user.changes # => {} user.changes_for_json # => {} user.name = 'Bob' user.attributes # => {:name=>"Bob"} ``` ## Installation Add this line to your application's Gemfile: ```ruby gem 'model_attribute' ``` And then execute: $ bundle Or install it yourself as: $ gem install model_attribute ## Testing Running specs: $ rspec ## Contributing 1. [Fork it](https://github.com/yammer/model_attribute/fork) 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes (`git commit -am 'Add some feature'`) 4. Push to the branch (`git push origin my-new-feature`) 5. Create a new Pull Request ## Code of Conduct This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. ================================================ FILE: Rakefile ================================================ require 'bundler/gem_tasks' require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) do |t| t.pattern = Dir.glob('spec/**/*_spec.rb') t.rspec_opts = '--format documentation' end task :default => :spec ================================================ FILE: lib/model_attribute/casts.rb ================================================ module ModelAttribute module Casts class << self def cast(value, type) return nil if value.nil? case type when :integer int = Integer(value) float = Float(value) raise ArgumentError, "Can't cast #{value.inspect} to an integer without loss of precision" unless int == float int when :float Float(value) when :boolean if !!value == value value elsif value == 't' || value == 'true' true elsif value == 'f' || value == 'false' false else raise ArgumentError, "Can't cast #{value.inspect} to boolean" end when :time case value when Time value when Date, DateTime value.to_time when Integer # Assume milliseconds since epoch. Time.at(value / 1000.0) when Numeric # Numeric, but not an integer. Assume seconds since epoch. Time.at(value) else Time.parse(value) end when :string String(value) when :json if valid_json?(value) value else raise ArgumentError, "JSON only supports nil, numeric, string, boolean and arrays and hashes of those." end else raise UnsupportedTypeError.new(type) end end private def valid_json?(value) (value == nil || value == true || value == false || value.is_a?(Numeric) || value.is_a?(String) || (value.is_a?(Array) && valid_json_array?(value)) || (value.is_a?(Hash) && valid_json_hash?(value) )) end def valid_json_array?(array) array.all? { |value| valid_json?(value) } end def valid_json_hash?(hash) hash.all? do |key, value| key.is_a?(String) && valid_json?(value) end end end end end ================================================ FILE: lib/model_attribute/errors.rb ================================================ module ModelAttribute class InvalidAttributeNameError < StandardError def initialize(attribute_name) super "Invalid attribute name #{attribute_name.inspect}" end end class UnsupportedTypeError < StandardError def initialize(type) types_list = ModelAttribute::SUPPORTED_TYPES.map(&:inspect).join(', ') super "Unsupported type #{type.inspect}. Must be one of #{types_list}." end end end ================================================ FILE: lib/model_attribute/version.rb ================================================ module ModelAttribute VERSION = "3.2.0" end ================================================ FILE: lib/model_attribute.rb ================================================ require "model_attribute/version" require "model_attribute/casts" require "model_attribute/errors" require "time" module ModelAttribute SUPPORTED_TYPES = [:integer, :float, :boolean, :string, :time, :json] def self.extended(base) base.send(:include, InstanceMethods) base.instance_variable_set('@attribute_names', []) base.instance_variable_set('@attribute_types', {}) base.instance_variable_set('@attribute_defaults', {}) end def attribute(name, type, opts = {}) name = name.to_sym type = type.to_sym raise UnsupportedTypeError.new(type) unless SUPPORTED_TYPES.include?(type) @attribute_names << name @attribute_types[name] = type @attribute_defaults[name] = opts[:default] if opts.key?(:default) self.class_eval(<<-CODE, __FILE__, __LINE__ + 1) def #{name}=(value) write_attribute(#{name.inspect}, value, #{type.inspect}) end def #{name} read_attribute(#{name.inspect}) end def #{name}_changed? !!changes[#{name.inspect}] end CODE if type == :boolean self.class_eval(<<-CODE, __FILE__, __LINE__ + 1) def #{name}? !!read_attribute(#{name.inspect}) end CODE end end def attributes @attribute_names end def attribute_defaults @attribute_defaults end module InstanceMethods def write_attribute(name, value, type = nil) name = name.to_sym # Don't want to expose attribute types as a method on the class, so access # via a back door. type ||= self.class.instance_variable_get('@attribute_types')[name] raise InvalidAttributeNameError.new(name) unless type value = Casts.cast(value, type) return if value == read_attribute(name) if changes.has_key? name original = changes[name].first else original = read_attribute(name) end if original == value changes.delete(name) else changes[name] = [original, value] end instance_variable_set("@#{name}", value) end def read_attribute(name) ivar_name = "@#{name}" if instance_variable_defined?(ivar_name) instance_variable_get(ivar_name) elsif !self.class.attributes.include?(name.to_sym) raise InvalidAttributeNameError.new(name) else self.class.attribute_defaults[name.to_sym] end end def attributes self.class.attributes.each_with_object({}) do |name, attributes| attributes[name] = read_attribute(name) end end def set_attributes(attributes, can_set_private_attrs = false) attributes.each do |key, value| send("#{key}=", value) if respond_to?("#{key}=", can_set_private_attrs) end end def ==(other) return true if equal?(other) if respond_to?(:id) other.kind_of?(self.class) && id == other.id else other.kind_of?(self.class) && attributes == other.attributes end end alias_method :eql?, :== def changes @changes ||= {} end # Attributes suitable for serializing to a JSON string. # # - Attribute keys are strings (for 'strict' JSON dumping). # - Attributes with a default or nil value are omitted to speed serialization. # - :time attributes are serialized as an Integer giving the number of # milliseconds since the epoch. def attributes_for_json self.class.attributes.each_with_object({}) do |name, attributes| value = read_attribute(name) if value != self.class.attribute_defaults[name.to_sym] value = (value.to_f * 1000).to_i if value.is_a? Time attributes[name.to_s] = value end end end # Changed attributes suitable for serializing to a JSON string. Returns a # hash from attribute name (as a string) to the new value of that attribute, # for attributes that have changed. # # - :time attributes are serialized as an Integer giving the number of # milliseconds since the epoch. # - Unlike attributes_for_json, attributes that have changed to a nil value # *are* included. def changes_for_json hash = {} changes.each do |attr_name, (_old_value, new_value)| new_value = (new_value.to_f * 1000).to_i if new_value.is_a? Time hash[attr_name.to_s] = new_value end hash end # Includes the class name and all the attributes and their values. e.g. # "#" def inspect attribute_string = self.class.attributes.map do |key| "#{key}: #{read_attribute(key).inspect}" end.join(', ') "#<#{self.class} #{attribute_string}>" end end end ================================================ FILE: model_attribute.gemspec ================================================ # coding: utf-8 lib = File.expand_path('../lib', __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'model_attribute/version' Gem::Specification.new do |spec| spec.name = "model_attribute" spec.version = ModelAttribute::VERSION spec.authors = ["David Waller"] spec.email = ["dwaller@yammer-inc.com"] spec.summary = %q{Attributes for non-ActiveRecord models} spec.description = <<-EOF Attributes for non-ActiveRecord models. Smaller and simpler than Virtus, and adds dirty tracking. EOF spec.homepage = "https://github.com/yammer/model_attribute" spec.license = "MIT" spec.files = `git ls-files -z`.split("\x0") spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ["lib"] spec.add_development_dependency "bundler" spec.add_development_dependency "rake", ">= 12.3.3" spec.add_development_dependency "rspec", "~> 3.1" end ================================================ FILE: performance_comparison.rb ================================================ require 'benchmark' $LOAD_PATH << "lib" Benchmark.bm(41) do |bm| bm.report("Virtus load") do require 'virtus' class VirtusUser include Virtus.model attribute :id, Integer attribute :name, String attribute :paid, Boolean attribute :updated_at, DateTime end end bm.report("ModelAttribute load") do require_relative 'lib/model_attribute' class ModelAttributeUser extend ModelAttribute attribute :id, :integer attribute :name, :string attribute :paid, :boolean attribute :updated_at, :time end end end Benchmark.bm(41) do |bm| vu = VirtusUser.new mau = ModelAttributeUser.new bm.report("Virtus assign integer") { 10_000.times { vu.id = rand(100_000) } } bm.report("ModelAttribute assign integer") { 10_000.times { mau.id = rand(100_000) } } bm.report("Virtus assign integer from string") { 10_000.times { vu.id = rand(100_000).to_s } } bm.report("ModelAttribute assign integer from string") { 10_000.times { mau.id = rand(100_000).to_s } } bm.report("Virtus assign time") { 10_000.times { vu.updated_at = Time.now } } bm.report("ModelAttribute assign time") { 10_000.times { mau.updated_at = Time.now } } bm.report("Virtus assign DateTime") { 10_000.times { vu.updated_at = DateTime.now } } bm.report("ModelAttribute assign DateTime") { 10_000.times { mau.updated_at = DateTime.now } } bm.report("Virtus assign time from epoch") { 10_000.times { vu.updated_at = Time.now.to_f } } bm.report("ModelAttribute assign time from epoch") { 10_000.times { mau.updated_at = Time.now.to_f } } bm.report("Virtus assign time from string") { 10_000.times { vu.updated_at = "2014-12-25 06:00:00" } } bm.report("ModelAttribute assign time from string") { 10_000.times { mau.updated_at = "2014-12-25 06:00:00" } } end __END__ $ ruby -v ruby 1.9.3p545 (2014-02-24 revision 45159) [x86_64-darwin13.3.0] $ ruby performance_comparison.rb user system total real Virtus load 0.120000 0.040000 0.160000 ( 0.207931) ModelAttribute load 0.020000 0.010000 0.030000 ( 0.027237) user system total real Virtus assign integer 0.010000 0.000000 0.010000 ( 0.013906) ModelAttribute assign integer 0.030000 0.000000 0.030000 ( 0.033674) Virtus assign integer from string 0.170000 0.000000 0.170000 ( 0.171892) ModelAttribute assign integer from string 0.050000 0.000000 0.050000 ( 0.042726) Virtus assign time 0.080000 0.000000 0.080000 ( 0.089792) ModelAttribute assign time 0.060000 0.000000 0.060000 ( 0.057887) Virtus assign DateTime 0.030000 0.000000 0.030000 ( 0.026447) ModelAttribute assign DateTime 0.200000 0.000000 0.200000 ( 0.204524) Virtus assign time from epoch 0.230000 0.010000 0.240000 ( 0.225557) ModelAttribute assign time from epoch 0.110000 0.000000 0.110000 ( 0.113315) Virtus assign time from string 0.260000 0.000000 0.260000 ( 0.264467) ModelAttribute assign time from string 0.450000 0.000000 0.450000 ( 0.444686) ================================================ FILE: spec/model_attributes_spec.rb ================================================ class User extend ModelAttribute attribute :id, :integer attribute :paid, :boolean attribute :name, :string attribute :created_at, :time attribute :profile, :json attribute :reward_points, :integer, default: 0 attribute :win_rate, :float def initialize(attributes = {}) set_attributes(attributes) end end class UserWithoutId extend ModelAttribute attribute :paid, :boolean attribute :name, :string attribute :created_at, :time def initialize(attributes = {}) set_attributes(attributes) end end RSpec.describe "a class using ModelAttribute" do describe "class methods" do describe ".attribute" do context "passed an unrecognised type" do it "raises an error" do expect do User.attribute :address, :custom_type end.to raise_error(ModelAttribute::UnsupportedTypeError, "Unsupported type :custom_type. " + "Must be one of :integer, :float, :boolean, :string, :time, :json.") end end end describe ".attributes" do it "returns an array of attribute names as symbols" do expect(User.attributes).to eq([:id, :paid, :name, :created_at, :profile, :reward_points, :win_rate]) end end describe ".attribute_defaults" do it "returns a hash of attributes that have non-nil defaults" do expect(User.attribute_defaults).to eq({reward_points: 0}) end end end describe "an instance of the class" do let(:user) { User.new } describe "an integer attribute (id)" do it "is nil when unset" do expect(user.id).to be_nil end it "stores an integer" do user.id = 3 expect(user.id).to eq(3) end it "stores an integer passed as a float" do user.id = 3.0 expect(user.id).to eq(3) end it "raises when passed a float with non-zero decimal part" do expect { user.id = 3.3 }.to raise_error(ArgumentError) end it "parses an integer string" do user.id = '3' expect(user.id).to eq(3) end it "raises if passed a string it can't parse" do expect { user.id = '3a' }.to raise_error(ArgumentError, /invalid value for Integer.*: "3a"/) end it "stores nil" do user.id = 3 user.id = nil expect(user.id).to be_nil end it "does not provide an id? method" do expect(user).to_not respond_to(:id?) expect { user.id? }.to raise_error(NoMethodError) end end describe "a float attribute (win_rate)" do it "stores a float" do user.win_rate = 35.62 expect(user.win_rate).to eq(35.62) end it "parses a float string" do user.win_rate = 35.62 expect(user.win_rate).to eq(35.62) end end describe "a boolean attribute (paid)" do it "is nil when unset" do expect(user.paid).to be_nil end it "stores true" do user.paid = true expect(user.paid).to eq(true) end it "stores false" do user.paid = false expect(user.paid).to eq(false) end it "parses 't' as true" do user.paid = 't' expect(user.paid).to eq(true) end it "parses 'f' as false" do user.paid = 'f' expect(user.paid).to eq(false) end it "parses 'false' to false" do user.paid = 'false' expect(user.paid).to eq(false) end it "parses 'true' to true" do user.paid = 'true' expect(user.paid).to eq(true) end it "raises if passed a string it can't parse" do expect { user.paid = '3a' }.to raise_error(ArgumentError, 'Can\'t cast "3a" to boolean') end it "stores nil" do user.paid = true user.paid = nil expect(user.paid).to be_nil end describe "#paid?" do it "returns false when unset" do expect(user.paid?).to eq(false) end it "returns false for false attributes" do user.paid = false expect(user.paid?).to eq(false) end it "returns true for true attributes" do user.paid = true expect(user.paid?).to eq(true) end end end describe "a string attribute (name)" do it "is nil when unset" do expect(user.name).to be_nil end it "stores a string" do user.name = 'Fred' expect(user.name).to eq('Fred') end it "casts an integer to a string" do user.name = 3 expect(user.name).to eq('3') end it "stores nil" do user.name = 'Fred' user.name = nil expect(user.name).to be_nil end it "does not provide a name? method" do expect(user).to_not respond_to(:name?) expect { user.name? }.to raise_error(NoMethodError) end end describe "a time attribute (created_at)" do let(:now_time) { Time.now } it "is nil when unset" do expect(user.created_at).to be_nil end it "stores a Time object" do user.created_at = now_time expect(user.created_at).to eq(now_time) end it "parses floats as seconds past the epoch" do user.created_at = now_time.to_f # Going via float loses precision, so use be_within expect(user.created_at).to be_within(0.0001).of(now_time) expect(user.created_at).to be_a_kind_of(Time) end it "parses integers as milliseconds past the epoch" do user.created_at = (now_time.to_f * 1000).to_i # Truncating to milliseconds loses precision, so use be_within expect(user.created_at).to be_within(0.001).of(now_time) expect(user.created_at).to be_a_kind_of(Time) end it "parses strings to date/times" do user.created_at = "2014-12-25 14:00:00 +0100" expect(user.created_at).to eq(Time.new(2014, 12, 25, 13, 00, 00, 0)) end it "raises for unparseable strings" do expect { user.created_at = "Today, innit?" }.to raise_error(ArgumentError, 'no time information in "Today, innit?"') end it "converts Dates to Time" do user.created_at = Date.parse("2014-12-25") expect(user.created_at).to eq(Time.new(2014, 12, 25, 00, 00, 00)) end it "converts DateTime to Time" do user.created_at = DateTime.parse("2014-12-25 13:00:45 +0000") expect(user.created_at.utc).to eq(Time.new(2014, 12, 25, 13, 00, 45, 0)) end it "stores nil" do user.created_at = now_time user.created_at = nil expect(user.created_at).to be_nil end it "does not provide a created_at? method" do expect(user).to_not respond_to(:created_at?) expect { user.created_at? }.to raise_error(NoMethodError) end end describe "a json attribute (profile)" do it "is nil when unset" do expect(user.profile).to be_nil end it "stores a string" do user.profile = 'Incomplete' expect(user.profile).to eq('Incomplete') end it "stores an integer" do user.profile = 3 expect(user.profile).to eq(3) end it "stores true" do user.profile = true expect(user.profile).to eq(true) end it "stores false" do user.profile = false expect(user.profile).to eq(false) end it "stores an array" do user.profile = [1, 2, 3] expect(user.profile).to eq([1, 2, 3]) end it "stores a hash" do user.profile = {'skill' => 8} expect(user.profile).to eq({'skill' => 8}) end it "stores nested hashes and arrays" do json = {'array' => [1, 2, true, {'inner' => true}, ['inside', {}] ], 'hash' => {'getting' => {'nested' => 'yes'}}, 'boolean' => true } user.profile = json expect(user.profile).to eq(json) end it "raises when passed an object not supported by JSON" do expect { user.profile = Object.new }.to raise_error(ArgumentError, "JSON only supports nil, numeric, string, boolean and arrays and hashes of those.") end it "raises when passed a hash with a non-string key" do expect { user.profile = {1 => 'first'} }.to raise_error(ArgumentError, "JSON only supports nil, numeric, string, boolean and arrays and hashes of those.") end it "raises when passed a hash with an unsupported value" do expect { user.profile = {'first' => :symbol} }.to raise_error(ArgumentError, "JSON only supports nil, numeric, string, boolean and arrays and hashes of those.") end it "raises when passed an array with an unsupported value" do expect { user.profile = [1, 2, nil, :symbol] }.to raise_error(ArgumentError, "JSON only supports nil, numeric, string, boolean and arrays and hashes of those.") end it "stores nil" do user.profile = {'foo' => 'bar'} user.profile = nil expect(user.profile).to be_nil end it "does not provide a profile? method" do expect(user).to_not respond_to(:profile?) expect { user.profile? }.to raise_error(NoMethodError) end end describe 'a defaulted attribute (reward_points)' do it "returns the default when unset" do expect(user.reward_points).to eq(0) end end describe "#write_attribute" do it "does the same casting as using the writer method" do user.write_attribute(:id, '3') expect(user.id).to eq(3) end it "raises an error if passed an invalid attribute name" do expect do user.write_attribute(:spelling_mistake, '3') end.to raise_error(ModelAttribute::InvalidAttributeNameError, "Invalid attribute name :spelling_mistake") end end describe "#read_attribute" do it "returns the value of an attribute that has been set" do user.write_attribute(:id, 3) expect(user.read_attribute(:id)).to eq(user.id) end it "returns nil for an attribute that has not been set" do expect(user.read_attribute(:id)).to be_nil end context "for an attribute with a default" do it "returns the default if the attribute has not been set" do expect(user.read_attribute(:reward_points)).to eq(0) end end it "raises an error if passed an invalid attribute name" do expect do user.read_attribute(:spelling_mistake) end.to raise_error(ModelAttribute::InvalidAttributeNameError, "Invalid attribute name :spelling_mistake") end end describe "#changes" do let(:changes) { user.changes } context "for a model instance created with no attributes except defaults" do it "is empty" do expect(changes).to be_empty end end context "when an attribute is set via a writer method" do before(:each) { user.id = 3 } it "has an entry from attribute name to [old, new] pair" do expect(changes).to include(:id => [nil, 3]) end context "when an attribute is set again" do before(:each) { user.id = 5 } it "shows the latest value for the attribute" do expect(changes).to include(:id => [nil, 5]) end end context "when an attribute is set back to its original value" do before(:each) { user.id = nil } it "does not have an entry for the attribute" do expect(changes).to_not include(:id) end end end end describe "#changes_for_json" do let(:changes_for_json) { user.changes_for_json } context "for a model instance created with no attributes" do it "is empty" do expect(changes_for_json).to be_empty end end context "when an attribute is set via a writer method" do before(:each) { user.id = 3 } it "has an entry from attribute name (as a string) to the new value" do expect(changes_for_json).to include('id' => 3) end context "when an attribute is set again" do before(:each) { user.id = 5 } it "shows the latest value for the attribute" do expect(changes_for_json).to include('id' => 5) end end context "when an attribute is set back to its original value" do before(:each) { user.id = nil } it "does not have an entry for the attribute" do expect(changes_for_json).to_not include('id') end end context "if the returned hash is modified" do before(:each) { user.changes_for_json.clear } it "does not affect subsequent results from changes_for_json" do expect(changes_for_json).to include('id' => 3) end end end it "serializes time attributes as JSON integer" do user.created_at = Time.now expect(changes_for_json).to include("created_at" => kind_of(Integer)) end end describe "#id_changed?" do context "with no changes" do it "returns false" do expect(user.id_changed?).to eq(false) end end context "with changes" do before(:each) { user.id = 3 } it "returns true" do expect(user.id_changed?).to eq(true) end end end describe "#attributes" do let(:time_now) { Time.now } before(:each) do user.id = 1 user.paid = true user.created_at = time_now end it "returns a hash including each set attribute" do expect(user.attributes).to include(id: 1, paid: true, created_at: time_now) end it "returns a hash with a nil value for each unset attribute" do expect(user.attributes).to include(name: nil) end end describe "#attributes_for_json" do let(:time_now) { Time.now } before(:each) do user.id = 1 user.paid = true user.created_at = time_now end it "serializes integer attributes as JSON integer" do expect(user.attributes_for_json).to include("id" => 1) end it "serializes time attributes as JSON integer" do expect(user.attributes_for_json).to include("created_at" => kind_of(Integer)) end it "serializes string attributes as JSON string" do user.name = 'Fred' expect(user.attributes_for_json).to include("name" => "Fred") end it "leaves JSON attributes unchanged" do json = {'interests' => ['coding', 'social networks'], 'rank' => 15} user.profile = json expect(user.attributes_for_json).to include("profile" => json) end it "omits attributes still set to the default value" do expect(user.attributes_for_json).to_not include("name", "reward_points") end it "includes an attribute changed from its default value" do user.name = "Fred" expect(user.attributes_for_json).to include("name" => "Fred") end it "includes an attribute changed from its default value to nil" do user.reward_points = nil expect(user.attributes_for_json).to include("reward_points" => nil) end end describe "#set_attributes" do it "allows mass assignment of attributes" do user.set_attributes(id: 5, name: "Sally") expect(user.attributes).to include(id: 5, name: "Sally") end it "ignores keys that have no writer method" do user.set_attributes(id: 5, species: "Human") expect(user.attributes).to_not include(species: "Human") end context "for an attribute with a private writer method" do before(:all) { User.send(:private, :name=) } after(:all) { User.send(:public, :name=) } it "does not set the attribute" do user.set_attributes(id: 5, name: "Sally") expect(user.attributes).to_not include(name: "Sally") end it "sets the attribute if the flag is passed" do user.set_attributes({id: 5, name: "Sally"}, true) expect(user.attributes).to include(name: "Sally") end end end describe "#inspect" do let(:user) do User.new(id: 1, name: "Fred", created_at: "2014-12-25 08:00 +0000", paid: true, profile: {'interests' => ['coding', 'social networks'], 'rank' => 15}, win_rate: 35.62) end it "includes integer attributes as 'name: value'" do expect(user.inspect).to include("id: 1") end it "includes boolean attributes as 'name: true/false'" do expect(user.inspect).to include("paid: true") end it "includes string attributes as 'name: \"string\"'" do expect(user.inspect).to include('name: "Fred"') end it "includes time attributes as 'name: '" do expect(user.inspect).to include("created_at: 2014-12-25 08:00:00 +0000") end it "includes json attributes as 'name: inspected_json'" do expect(user.inspect).to include('profile: {"interests"=>["coding", "social networks"], "rank"=>15}') end it "includes defaulted attributes" do expect(user.inspect).to include('reward_points: 0') end it "includes the class name" do expect(user.inspect).to include("User") end it "looks like '#'" do expect(user.inspect).to eq("#[\"coding\", \"social networks\"], \"rank\"=>15}, reward_points: 0, win_rate: 35.62>") end end describe 'equality with :id field' do let(:u1) { User.new(id: 1, name: 'David') } context '#==' do it 'returns true when ids match, regardless of other attributes' do u2 = User.new(id: 1, name: 'Dave') expect(u1).to eq(u2) end it 'returns false when ids do not match' do u2 = User.new(id: 2, name: 'David') expect(u1).to_not eq(u2) end end context '#eql?' do it 'returns true when ids match, regardless of other attributes' do u2 = User.new(id: 1, name: 'Dave') expect(u1).to eql(u2) end it 'returns false when ids do not match' do u2 = User.new(id: 2, name: 'David') expect(u1).to_not eql(u2) end end end describe 'equality without :id field' do let(:u1) { UserWithoutId.new(name: 'David') } context "for models with different attribute values" do let(:u2) { UserWithoutId.new(name: 'Dave') } it "#== returns false" do expect(u1).to_not eq(u2) end it "#eql? returns false" do expect(u1).to_not eql(u2) end end context "for models with different attributes set" do let(:u2) { UserWithoutId.new } it "#== returns false" do expect(u1).to_not eq(u2) end it "#eql? returns false" do expect(u1).to_not eql(u2) end end context "for models with the same attributes set to the same values" do let(:u2) { UserWithoutId.new(name: 'David') } it "#== returns true" do expect(u1).to eq(u2) end it "#eql? returns true" do expect(u1).to eql(u2) end end end end end ================================================ FILE: spec/spec_helper.rb ================================================ require 'model_attribute' # This file was generated by the `rspec --init` command. Conventionally, all # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. # The generated `.rspec` file contains `--require spec_helper` which will cause this # file to always be loaded, without a need to explicitly require it in any files. # # Given that it is always loaded, you are encouraged to keep this file as # light-weight as possible. Requiring heavyweight dependencies from this file # will add to the boot time of your test suite on EVERY test run, even for an # individual file that may not need all of that loaded. Instead, consider making # a separate helper file that requires the additional dependencies and performs # the additional setup, and require it from the spec files that actually need it. # # The `.rspec` file also contains a few flags that are not defaults but that # users commonly want. # # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration RSpec.configure do |config| # rspec-expectations config goes here. You can use an alternate # assertion/expectation library such as wrong or the stdlib/minitest # assertions if you prefer. config.expect_with :rspec do |expectations| # This option will default to `true` in RSpec 4. It makes the `description` # and `failure_message` of custom matchers include text for helper methods # defined using `chain`, e.g.: # be_bigger_than(2).and_smaller_than(4).description # # => "be bigger than 2 and smaller than 4" # ...rather than: # # => "be bigger than 2" expectations.include_chain_clauses_in_custom_matcher_descriptions = true end # rspec-mocks config goes here. You can use an alternate test double # library (such as bogus or mocha) by changing the `mock_with` option here. config.mock_with :rspec do |mocks| # Prevents you from mocking or stubbing a method that does not exist on # a real object. This is generally recommended, and will default to # `true` in RSpec 4. mocks.verify_partial_doubles = true end # Limits the available syntax to the non-monkey patched syntax that is recommended. # For more details, see: # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ # - http://myronmars.to/n/dev-blog/2014/05/notable-changes-in-rspec-3#new__config_option_to_disable_rspeccore_monkey_patching config.disable_monkey_patching! # This setting enables warnings. It's recommended, but in some cases may # be too noisy due to issues in dependencies. config.warnings = true # The settings below are suggested to provide a good initial experience # with RSpec, but feel free to customize to your heart's content. =begin # These two settings work together to allow you to limit a spec run # to individual examples or groups you care about by tagging them with # `:focus` metadata. When nothing is tagged with `:focus`, all examples # get run. config.filter_run :focus config.run_all_when_everything_filtered = true # Many RSpec users commonly either run the entire suite or an individual # file, and it's useful to allow more verbose output when running an # individual spec file. if config.files_to_run.one? # Use the documentation formatter for detailed output, # unless a formatter has already been configured # (e.g. via a command-line flag). config.default_formatter = 'doc' end # Print the 10 slowest examples and example groups at the # end of the spec run, to help surface which specs are running # particularly slow. config.profile_examples = 10 # Run specs in random order to surface order dependencies. If you find an # order dependency and want to debug it, you can fix the order by providing # the seed, which is printed after each run. # --seed 1234 config.order = :random # Seed global randomization in this process using the `--seed` CLI option. # Setting this allows you to use `--seed` to deterministically reproduce # test failures related to randomization by passing the same `--seed` value # as the one that triggered the failure. Kernel.srand config.seed =end end