├── .coveralls.yml ├── .rspec ├── Rakefile ├── lib ├── attribute-depends-calculator.rb ├── attribute_depends_calculator │ ├── version.rb │ ├── engine.rb │ ├── macro.rb │ ├── parameter.rb │ └── factory.rb └── attribute_depends_calculator.rb ├── Gemfile ├── .travis.yml ├── bin ├── setup └── console ├── .gitignore ├── Gemfile.global ├── spec ├── support │ ├── models.rb │ └── schema.rb ├── spec_helper.rb └── attribute_depends_calculator_spec.rb ├── attribute-depends-calculator.gemspec └── README.md /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-ci 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | task :default => :spec 3 | -------------------------------------------------------------------------------- /lib/attribute-depends-calculator.rb: -------------------------------------------------------------------------------- 1 | require 'attribute_depends_calculator' 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile('Gemfile.global') 2 | 3 | group :test do 4 | gem 'rails', '4.2.5' 5 | end 6 | -------------------------------------------------------------------------------- /lib/attribute_depends_calculator/version.rb: -------------------------------------------------------------------------------- 1 | 2 | module AttributeDependsCalculator 3 | VERSION = '0.2' 4 | end 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0 4 | - 2.1.5 5 | - 2.2 6 | - 2.3.0 7 | script: xvfb-run rspec 8 | before_install: gem install bundler -v 1.11.2 9 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | .ruby-version 11 | .idea/ 12 | *.gem 13 | *.un~ 14 | -------------------------------------------------------------------------------- /lib/attribute_depends_calculator.rb: -------------------------------------------------------------------------------- 1 | #encoding: utf-8 2 | 3 | Gem.find_files('attribute_depends_calculator/**/*.rb').each { |file| require file } 4 | 5 | module AttributeDependsCalculator 6 | end 7 | -------------------------------------------------------------------------------- /Gemfile.global: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem 'sqlite3', '>=1.3.6' 7 | gem 'rspec', '~> 2.11' 8 | gem 'database_cleaner' 9 | gem 'coveralls', require: false 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | class Product < ActiveRecord::Base 2 | end 3 | 4 | class OrderItem < ActiveRecord::Base 5 | belongs_to :order 6 | belongs_to :product 7 | end 8 | 9 | 10 | class Order < ActiveRecord::Base 11 | has_many :order_items 12 | depend price: {order_items: :price} 13 | end 14 | -------------------------------------------------------------------------------- /lib/attribute_depends_calculator/engine.rb: -------------------------------------------------------------------------------- 1 | 2 | module AttributeDependsCalculator 3 | 4 | class Engine < ::Rails::Engine 5 | isolate_namespace AttributeDependsCalculator 6 | initializer 'attribute_depends_calculator.extend_active_record' do 7 | ActiveRecord::Base.send :extend, AttributeDependsCalculator::Macro 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/attribute_depends_calculator/macro.rb: -------------------------------------------------------------------------------- 1 | module AttributeDependsCalculator 2 | module Macro 3 | 4 | def attribute_depend(options) 5 | options.each do |column, option| 6 | Factory.new(self, column, option).perform 7 | end 8 | end 9 | 10 | alias_method :depend, :attribute_depend 11 | end 12 | end 13 | 14 | ActiveRecord::Base.send :extend, AttributeDependsCalculator::Macro 15 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require 'rails/all' 5 | require "attribute-depends-calculator" 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define do 2 | 3 | create_table :orders do |t| 4 | t.decimal :price, precision: 10, scale: 2, null: false 5 | t.integer :count, defautl: 0 6 | t.timestamps null: false 7 | end 8 | 9 | create_table :order_items do |t| 10 | t.integer :order_id, index: true, null: false 11 | t.integer :product_id, index: true 12 | t.integer :user_id, index: true 13 | t.integer :quantity 14 | t.decimal :price, precision: 10, scale: 2, null: false 15 | t.timestamps null: false 16 | end 17 | 18 | create_table :products do |t| 19 | t.string :name 20 | t.decimal :price, precision: 10, scale: 2, null: false 21 | t.timestamps null: false 22 | end 23 | 24 | end 25 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | require "rubygems" 3 | require "rails" 4 | require "active_record" 5 | require 'database_cleaner' 6 | require 'attribute-depends-calculator' 7 | require 'coveralls' 8 | 9 | Coveralls.wear! 10 | 11 | module Rails 12 | class << self 13 | def root 14 | File.expand_path("../spec", __FILE__) 15 | end 16 | end 17 | end 18 | 19 | ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', 20 | :database => ':memory:') 21 | 22 | # Requires supporting files with schema, models, etc, 23 | # in ./support/ and its subdirectories. 24 | Dir[File.expand_path(File.join(File.dirname(__FILE__),'support','**','*.rb'))].each { |f| require f } 25 | 26 | RSpec.configure do |config| 27 | 28 | config.before(:suite) do 29 | DatabaseCleaner.strategy = :transaction 30 | DatabaseCleaner.clean_with :truncation 31 | end 32 | 33 | config.before(:each) do 34 | DatabaseCleaner.start 35 | end 36 | 37 | config.after(:each) do 38 | DatabaseCleaner.clean 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /lib/attribute_depends_calculator/parameter.rb: -------------------------------------------------------------------------------- 1 | module AttributeDependsCalculator 2 | class Parameter 3 | 4 | attr_accessor :params, :depend_association_name, :depend_column, :operator, :expression 5 | 6 | OPERATORS = %i(+ *) 7 | METHODS = %i(sum average count minimum maximum) 8 | 9 | def initialize(params) 10 | self.operator = params.values_at(:operator).first || :sum 11 | self.params = params 12 | fetch 13 | end 14 | 15 | def fetch 16 | self.depend_association_name = params.keys.first 17 | self.depend_column = params.values.first 18 | operator_filter 19 | end 20 | 21 | def operator_filter 22 | self.expression = if OPERATORS.include? operator 23 | "pluck(:#{depend_column}).compact.reduce(0, :#{operator})" 24 | elsif METHODS.include? operator 25 | "#{operator}(:#{depend_column})" 26 | elsif operator.kind_of? Proc 27 | @proc = operator 28 | else 29 | raise 'The operator of depends calculator are incorrect' 30 | end 31 | end 32 | 33 | def proc? 34 | !@proc.nil? 35 | end 36 | 37 | def callback 38 | @proc 39 | end 40 | 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /attribute-depends-calculator.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'attribute_depends_calculator/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "attribute-depends-calculator" 8 | spec.version = AttributeDependsCalculator::VERSION 9 | spec.authors = ["Jason Hou"] 10 | spec.email = ["hjj1992@gmail.com"] 11 | 12 | spec.summary = %q{calculate depends attribute automatically} 13 | spec.description = %q{auto calculate collect of depends attribute value and save when that changes} 14 | spec.homepage = "https://github.com/falm/attribute-depends-calculator" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.bindir = "exe" 18 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 19 | spec.require_paths = ["lib"] 20 | spec.licenses = ["MIT"] 21 | 22 | spec.required_ruby_version = '>= 2.0' 23 | spec.add_development_dependency "bundler", "~> 1.11" 24 | spec.add_development_dependency "rake", "~> 10.0" 25 | spec.add_development_dependency 'rack', '~> 1.5' 26 | end 27 | -------------------------------------------------------------------------------- /spec/attribute_depends_calculator_spec.rb: -------------------------------------------------------------------------------- 1 | #encoding: utf-8 2 | 3 | require "spec_helper" 4 | 5 | describe AttributeDependsCalculator do 6 | 7 | let(:order) {Order.create(price: 100) } 8 | let(:product) {Product.create(price: 100, name: 'iPhone')} 9 | let(:order_item) {OrderItem.create(order: order, product: product, price: product.price)} 10 | 11 | context 'Macro' do 12 | it 'should respond to depends macro' do 13 | expect(ActiveRecord::Base.respond_to?(:depend)).to be true 14 | expect(ActiveRecord::Base.respond_to?(:attribute_depend)).to be true 15 | end 16 | end 17 | 18 | context 'Depends' do 19 | 20 | it 'should auto calculate when order item changes' do 21 | expect(order.price).to eq(order_item.price) 22 | order_item.update(price: 120) 23 | expect(order.reload.price).to eq(120) 24 | end 25 | 26 | it 'should auto calculate when create new order item' do 27 | origin_price = order.price 28 | OrderItem.create(order: order, product: order_item.product, price: 244) 29 | expect(order.reload.price).to eq(origin_price + 244) 30 | end 31 | 32 | it 'should auto calculate when destroy an order item' do 33 | origin_price = order.price 34 | OrderItem.create(order: order, product: order_item.product, price: origin_price) 35 | expect(order.reload.price).to eq(origin_price * 2) 36 | OrderItem.destroy(order.id) 37 | expect(order.reload.price).to eq(origin_price) 38 | end 39 | 40 | end 41 | 42 | context 'Parameter' do 43 | 44 | it 'should raise error when operator incorrect' do 45 | 46 | expect do 47 | Order.class_eval do 48 | attribute_depend price: {order_items: :price, operator: :divide} 49 | end 50 | end.to raise_error 51 | 52 | end 53 | 54 | it 'should proc params words' do 55 | discount = 0.8 56 | Order.class_eval do 57 | attribute_depend price: {order_items: :price, operator: -> (relation) { relation.sum(:price) * discount } } 58 | end 59 | 60 | OrderItem.create(order: order, product: order_item.product, price: order.price) 61 | 62 | order.reload 63 | expect(order.price).to eq(order.order_items.sum(:price) * discount) 64 | 65 | end 66 | 67 | end 68 | 69 | end 70 | -------------------------------------------------------------------------------- /lib/attribute_depends_calculator/factory.rb: -------------------------------------------------------------------------------- 1 | module AttributeDependsCalculator 2 | class Factory 3 | 4 | attr_accessor :klass, :column, :association 5 | 6 | attr_accessor :parameter 7 | 8 | extend Forwardable 9 | 10 | def_delegators :parameter, :depend_association_name, :depend_column, :expression, :proc?, :callback 11 | 12 | def initialize(klass, column, params) 13 | self.klass, self.column = klass, column 14 | self.parameter = Parameter.new(params) 15 | self.association = fetch_association 16 | end 17 | 18 | def perform 19 | define_calculator 20 | define_operator_callback 21 | append_callbacks 22 | end 23 | 24 | private 25 | 26 | def define_calculator 27 | self.klass.class_eval <<-METHOD, __FILE__, __LINE__ + 1 28 | def #{calculate_method_name} 29 | total = #{calculate} 30 | update(:#{column} => total) 31 | end 32 | METHOD 33 | end 34 | 35 | def calculate 36 | if self.proc? 37 | "self.#{column}_depends_callback(#{depend_association_name})" 38 | else 39 | "self.#{depend_association_name}.#{expression}" 40 | end 41 | end 42 | 43 | def define_operator_callback 44 | callback, column = self.callback, self.column 45 | return if callback.nil? 46 | self.klass.class_eval do 47 | define_method "#{column}_depends_callback", &callback 48 | end 49 | end 50 | 51 | def calculate_method_name 52 | "calculate_and_update_#{column}" 53 | end 54 | 55 | def klass_assoc_name 56 | @assoc_name ||= association.reflect_on_all_associations.find {|assoc| assoc.plural_name == klass.table_name}.name 57 | end 58 | 59 | def fetch_association 60 | ObjectSpace.const_get klass.reflect_on_association(depend_association_name).class_name 61 | end 62 | 63 | def append_callbacks 64 | append_callback_hook 65 | define_callback_methods 66 | end 67 | 68 | def append_callback_hook 69 | self.association.class_eval <<-METHOD, __FILE__, __LINE__ + 1 70 | after_save :depends_update_#{column} 71 | around_destroy :depends_update_#{column}_around 72 | METHOD 73 | end 74 | 75 | def define_callback_methods 76 | self.association.class_eval <<-METHOD, __FILE__, __LINE__ + 1 77 | private 78 | 79 | def depends_update_#{column} 80 | self.#{klass_assoc_name}.#{calculate_method_name} 81 | end 82 | 83 | def depends_update_#{column}_around 84 | _relation = self.#{klass_assoc_name} 85 | yield 86 | _relation.#{calculate_method_name} 87 | end 88 | METHOD 89 | end 90 | 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Attribute Depends Calculator 2 | [![Build Status](https://travis-ci.org/falm/attribute-depends-calculator.svg?branch=master)](https://travis-ci.org/falm/attribute-depends-calculator) [![Coverage Status](https://coveralls.io/repos/github/falm/attribute-depends-calculator/badge.svg?branch=master)](https://coveralls.io/github/falm/attribute-depends-calculator?branch=master) [![Code Climate](https://codeclimate.com/github/falm/attribute-depends-calculator/badges/gpa.svg)](https://codeclimate.com/github/falm/attribute-depends-calculator) [![Dependency Status](https://gemnasium.com/badges/github.com/falm/attribute-depends-calculator.svg)](https://gemnasium.com/github.com/falm/attribute-depends-calculator) [![Gem Version](https://badge.fury.io/rb/attribute-depends-calculator.svg)](https://badge.fury.io/rb/attribute-depends-calculator) 3 | 4 | The scenario of the gem is when you have an attribute on model that value depends of a calculation of other model's attribute which attribute's model related. AttributeDependsCalculator will help you solve the case 5 | 6 | ## Installation 7 | 8 | Add this line to your application's Gemfile: 9 | 10 | ```ruby 11 | gem 'attribute-depends-calculator' 12 | ``` 13 | 14 | And then execute: 15 | 16 | $ bundle 17 | 18 | ## Usage 19 | Assume you have model order and order-item 20 | ```ruby 21 | class Order < ActiveRecord::Base 22 | has_many :order_items 23 | depend total_price: {order_items: :price} 24 | end 25 | 26 | class OrderItem < ActiveRecord::Base 27 | belongs_to :order 28 | end 29 | ``` 30 | And you can 31 | ```ruby 32 | order = Order.first 33 | order.total_price 34 | #=> 100.0 35 | order.order_items.pluck(:price) 36 | #=> [50.0, 50.0] 37 | order_item = order.order_items.first 38 | order_item.update(price: 100) 39 | order.reload.total_price 40 | #=> 150.0 41 | ``` 42 | As above show the price of order automatically update when whatever order_items changes 43 | 44 | ## Advanced 45 | 46 | The options **operator** had two cateogries of value, the default value of the gem is expression **sum** 47 | 48 | #### Operation 49 | 50 | ```ruby 51 | class Order < ActiveRecord::Base 52 | has_many :order_items 53 | depend total_price: {order_items: :price, operator: :+} # or :* 54 | end 55 | ``` 56 | 57 | #### Expression 58 | 59 | The following expression can be use to calculate the collection of depends attributes 60 | 61 | **sum** 62 | 63 | ```ruby 64 | class Order < ActiveRecord::Base 65 | ... 66 | depend total_price: {order_items: :price, operator: :sum} # default 67 | end 68 | ``` 69 | 70 | **average** 71 | 72 | ```ruby 73 | class Order < ActiveRecord::Base 74 | ... 75 | depend avg_price: {order_items: :price, operator: :average} 76 | end 77 | ``` 78 | 79 | **count** 80 | 81 | ```ruby 82 | class Order < ActiveRecord::Base 83 | ... 84 | depend order_items_count: {order_items: :price, operator: :count} 85 | end 86 | ``` 87 | 88 | **minimum** 89 | 90 | ```ruby 91 | class Order < ActiveRecord::Base 92 | ... 93 | depend min_price: {order_items: :price, operator: :minimum} 94 | end 95 | ``` 96 | 97 | **maximum** 98 | 99 | ```ruby 100 | class Order < ActiveRecord::Base 101 | ... 102 | depend max_price: {order_items: :price, operator: :maximum} 103 | end 104 | ``` 105 | 106 | 107 | **Proc** 108 | 109 | Proc can be passing in operator option, and the only one params is the active reload of the depended relate Model 110 | 111 | ```ruby 112 | class Order < ActiveRecord::Base 113 | ... 114 | depend discount_price: {order_items: :price, operator: -> (items) { items.sum(:price) * discount } } 115 | end 116 | ``` 117 | 118 | 119 | 120 | 121 | 122 | ## Contributing 123 | Bug reports and pull requests are welcome [on GitHub](https://github.com/falm/attribute-depends-calculator) 124 | 125 | ## License 126 | MIT © [Falm](https://github.com/falm) 127 | --------------------------------------------------------------------------------