├── .gitignore ├── .travis.yml ├── Gemfile ├── MIT-LICENSE ├── README.md ├── Rakefile ├── benchmark.rb ├── bin ├── console └── setup ├── gemfiles ├── rails_50.gemfile ├── rails_51.gemfile ├── rails_52.gemfile ├── rails_60.gemfile ├── rails_edge.gemfile └── rails_edge.gemfile.lock ├── lib ├── lightweight_attributes.rb └── lightweight_attributes │ ├── attribute_set.rb │ ├── attribute_set │ └── builder.rb │ ├── base_class_methods.rb │ ├── base_methods.rb │ └── railtie.rb ├── lightweight_attributes.gemspec └── test ├── config └── database.yml ├── db └── .keep ├── dummy_app.rb ├── lightweight_attributes_test.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | Gemfile.lock 10 | gemfiles/*.lock 11 | /log/ 12 | /test/log/ 13 | .byebug_history 14 | *.sqlite3 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | dist: xenial 4 | 5 | services: 6 | - postgresql 7 | - mysql 8 | 9 | rvm: 10 | - 2.6.3 11 | 12 | cache: bundler 13 | 14 | before_install: gem up bundler 15 | 16 | env: 17 | - DB=sqlite3 18 | - DB=mysql 19 | - DB=postgresql 20 | 21 | gemfile: 22 | - gemfiles/rails_50.gemfile 23 | - gemfiles/rails_51.gemfile 24 | - gemfiles/rails_52.gemfile 25 | - gemfiles/rails_60.gemfile 26 | - gemfiles/rails_edge.gemfile 27 | 28 | matrix: 29 | allow_failures: 30 | - gemfile: gemfiles/rails_edge.gemfile 31 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in lightweight_attributes.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Akira Matsuda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lightweight_attributes 2 | 3 | lightweight_attributes is a tiny monkey-patch for making Active Record model objects ultimately lightweight. 4 | 5 | 6 | ## Benchmarks 7 | 8 | Here's a result of a simple benchmark measuring memory usage, number of method calls, and execution time with and without this gem, for fetching 10,000 records from a MySQL database and iterating over each of them and reading each attribute. 9 | The benchmark is included in this repo so everyone can try. 10 | 11 | ### Allocated Memory and Allocated Objects 12 | 13 | lightweight_attributes halves the memory allocation and reduces the object creation to be 25%! 14 | 15 | ``` 16 | $ bundle e ruby benchmark.rb memory 17 | ****************************** ActiveModel::AttributeSet ****************************** 18 | Total allocated: 26969712 bytes (270001 objects) 19 | Total retained: 24649712 bytes (260001 objects) 20 | 21 | allocated memory by class 22 | ----------------------------------- 23 | 12800000 Hash 24 | 8000000 ActiveModel::Attribute::FromDatabase 25 | 3600000 String 26 | 1200000 Model 27 | 880000 ActiveModel::LazyAttributeHash 28 | 400000 ActiveModel::AttributeSet 29 | 89712 Array 30 | 31 | allocated objects by class 32 | ----------------------------------- 33 | 100000 ActiveModel::Attribute::FromDatabase 34 | 90000 String 35 | 50000 Hash 36 | 10000 ActiveModel::AttributeSet 37 | 10000 ActiveModel::LazyAttributeHash 38 | 10000 Model 39 | 1 Array 40 | 41 | 42 | ****************************** LightweightAttributes ****************************** 43 | Total allocated: 14889712 bytes (70001 objects) 44 | Total retained: 12569712 bytes (60001 objects) 45 | 46 | allocated memory by class 47 | ----------------------------------- 48 | 12800000 Hash 49 | 1200000 Model 50 | 800000 LightweightAttributes::AttributeSet 51 | 89712 Array 52 | 53 | allocated objects by class 54 | ----------------------------------- 55 | 50000 Hash 56 | 10000 LightweightAttributes::AttributeSet 57 | 10000 Model 58 | 1 Array 59 | 60 | ``` 61 | 62 | ### Number of Method Calls 63 | 64 | lightweight_attributes reduces method calls to be less than 40%! 65 | 66 | ``` 67 | $ bundle e ruby benchmark.rb methods 68 | ****************************** ActiveModel::AttributeSet ****************************** 69 | {"ActiveRecord::Result#each"=>1, 70 | "ActiveRecord::Result#hash_rows"=>1, 71 | "ActiveRecord::Persistence::ClassMethods#instantiate"=>10000, 72 | "ActiveRecord::Inheritance::ClassMethods#discriminate_class_for_record"=>10000, 73 | "ActiveRecord::Inheritance::ClassMethods#using_single_table_inheritance?"=>10000, 74 | "ActiveRecord::ModelSchema::ClassMethods#inheritance_column"=>20000, 75 | "Object#present?"=>10000, 76 | "NilClass#blank?"=>10000, 77 | "ActiveRecord::Persistence::ClassMethods#discriminate_class_for_record"=>10000, 78 | "ActiveRecord::ModelSchema::ClassMethods#attributes_builder"=>10000, 79 | "ActiveModel::AttributeSet::Builder#build_from_database"=>10000, 80 | "ActiveModel::LazyAttributeHash#initialize"=>10000, 81 | "ActiveModel::AttributeSet#initialize"=>10000, 82 | "ActiveRecord::Core::ClassMethods#allocate"=>10000, 83 | "ActiveRecord::AttributeMethods::ClassMethods#define_attribute_methods"=>20000, 84 | "ActiveRecord::Core#init_with"=>10000, 85 | "##convert"=>10000, 86 | "ActiveRecord::ModelSchema::ClassMethods#yaml_encoder"=>10000, 87 | "ActiveModel::AttributeSet::YAMLEncoder#decode"=>10000, 88 | "ActiveRecord::Aggregations#init_internals"=>10000, 89 | "ActiveRecord::Associations#init_internals"=>10000, 90 | "ActiveRecord::Core#init_internals"=>10000, 91 | "ActiveRecord::Base#_run_find_callbacks"=>10000, 92 | "ActiveSupport::Callbacks#run_callbacks"=>20000, 93 | "ActiveRecord::Base#__callbacks"=>20000, 94 | "##__callbacks"=>20000, 95 | "ActiveSupport::Callbacks::CallbackChain#empty?"=>20000, 96 | "ActiveRecord::Base#_run_initialize_callbacks"=>10000, 97 | "ActiveRecord::AttributeMethods::PrimaryKey#id"=>10000, 98 | "ActiveRecord::Transactions#sync_with_transaction_state"=>10000, 99 | "ActiveRecord::Transactions#update_attributes_from_transaction_state"=>10000, 100 | "ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods#primary_key"=>10000, 101 | "ActiveRecord::AttributeMethods::Read#_read_attribute"=>100000, 102 | "ActiveModel::AttributeSet#fetch_value"=>100000, 103 | "ActiveModel::AttributeSet#[]"=>100000, 104 | "ActiveModel::LazyAttributeHash#[]"=>100000, 105 | "ActiveModel::LazyAttributeHash#assign_default_value"=>100000, 106 | "##from_database"=>100000, 107 | "ActiveModel::Attribute#initialize"=>100000, 108 | "ActiveModel::Attribute#value"=>100000, 109 | "ActiveModel::Attribute::FromDatabase#type_cast"=>100000, 110 | "ActiveModel::Type::Integer#deserialize"=>10000, 111 | "##__temp__36f6c613"=>10000, 112 | "ActiveModel::Type::Value#deserialize"=>90000, 113 | "ActiveModel::Type::Value#cast"=>90000, 114 | "ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter::MysqlString#cast_value"=>90000, 115 | "ActiveModel::Type::String#cast_value"=>90000, 116 | "##__temp__36f6c623"=>10000, 117 | "##__temp__36f6c633"=>10000, 118 | "##__temp__36f6c643"=>10000, 119 | "##__temp__36f6c653"=>10000, 120 | "##__temp__36f6c663"=>10000, 121 | "##__temp__36f6c673"=>10000, 122 | "##__temp__36f6c683"=>10000, 123 | "##__temp__36f6c693"=>10000} 124 | 125 | 126 | ****************************** LightweightAttributes ****************************** 127 | {"ActiveRecord::Result#each"=>1, 128 | "ActiveRecord::Result#hash_rows"=>1, 129 | "ActiveRecord::Persistence::ClassMethods#instantiate"=>10000, 130 | "ActiveRecord::Inheritance::ClassMethods#discriminate_class_for_record"=>10000, 131 | "ActiveRecord::Inheritance::ClassMethods#using_single_table_inheritance?"=>10000, 132 | "ActiveRecord::ModelSchema::ClassMethods#inheritance_column"=>20000, 133 | "Object#present?"=>10000, 134 | "NilClass#blank?"=>10000, 135 | "ActiveRecord::Persistence::ClassMethods#discriminate_class_for_record"=>10000, 136 | "ActiveRecord::ModelSchema::ClassMethods#attributes_builder"=>10000, 137 | "LightweightAttributes::AttributeSet::Builder#build_from_database"=>10000, 138 | "LightweightAttributes::AttributeSet#initialize"=>10000, 139 | "ActiveRecord::Core::ClassMethods#allocate"=>10000, 140 | "ActiveRecord::AttributeMethods::ClassMethods#define_attribute_methods"=>20000, 141 | "ActiveRecord::Core#init_with"=>10000, 142 | "##convert"=>10000, 143 | "ActiveRecord::ModelSchema::ClassMethods#yaml_encoder"=>10000, 144 | "ActiveModel::AttributeSet::YAMLEncoder#decode"=>10000, 145 | "ActiveRecord::Aggregations#init_internals"=>10000, 146 | "ActiveRecord::Associations#init_internals"=>10000, 147 | "ActiveRecord::Core#init_internals"=>10000, 148 | "ActiveRecord::Base#_run_find_callbacks"=>10000, 149 | "ActiveSupport::Callbacks#run_callbacks"=>20000, 150 | "ActiveRecord::Base#__callbacks"=>20000, 151 | "##__callbacks"=>20000, 152 | "ActiveSupport::Callbacks::CallbackChain#empty?"=>20000, 153 | "ActiveRecord::Base#_run_initialize_callbacks"=>10000, 154 | "ActiveRecord::AttributeMethods::PrimaryKey#id"=>10000, 155 | "ActiveRecord::Transactions#sync_with_transaction_state"=>10000, 156 | "ActiveRecord::Transactions#update_attributes_from_transaction_state"=>10000, 157 | "ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods#primary_key"=>10000, 158 | "ActiveRecord::AttributeMethods::Read#_read_attribute"=>100000, 159 | "LightweightAttributes::AttributeSet#fetch_value"=>100000, 160 | "ActiveModel::Type::Integer#deserialize"=>10000, 161 | "##__temp__36f6c613"=>10000, 162 | "##__temp__36f6c623"=>10000, 163 | "##__temp__36f6c633"=>10000, 164 | "##__temp__36f6c643"=>10000, 165 | "##__temp__36f6c653"=>10000, 166 | "##__temp__36f6c663"=>10000, 167 | "##__temp__36f6c673"=>10000, 168 | "##__temp__36f6c683"=>10000, 169 | "##__temp__36f6c693"=>10000} 170 | ``` 171 | 172 | ### Elapsed Time 173 | 174 | lightweight_attributes makes it 4x faster! 175 | 176 | ``` 177 | $ bundle e ruby benchmark.rb time 178 | ****************************** ActiveModel::AttributeSet ****************************** 179 | 1.590478 180 | 181 | ****************************** LightweightAttributes ****************************** 182 | 0.384524 183 | ``` 184 | 185 | ## Installation 186 | 187 | Just bundle `'lightweight_attributes'` gem on your Rails apps, and you're all set. No configurations! 188 | 189 | 190 | ## Usage 191 | 192 | It just works. I said, no configurations. 193 | This gem changes Active Record's data structure without changing any public API, so you don't need to change any single line of code in your application. 194 | If bundling this gem introduces any incompatibility besides making things fast and less memory-consuming, that should be a bug. 195 | 196 | 197 | ## Why ActiveRecord::Base Is Slow and Heavy, and How This Gem Makes It Fast and Lightweight? 198 | 199 | ### The Active Record Object 200 | 201 | Active Record model object is a super hero that plays multiple roles. 202 | One is "form object". For this use case, we usually create just one object per request, so we don't have to care about performance of this object. 203 | 204 | Another use case is "data transfer object". For this purpose, we would create hundreds of objects for rendering one HTML page. We would even create millions of model objects for APIs or batch systems. 205 | In this case, size of each object deeply impacts the whole system performance. 206 | 207 | ### The Attribute API 208 | 209 | As a version 4.0 feature, Active Record eqiupped a new API named "attributes API" that handles type casting between user, application, and database. 210 | However, this new feature caused a significant performance regression. 211 | For that purpose, each attribute per each model instance holds an instance of `Attribute` object that handles all the heavy lifting works around type casting. 212 | 213 | Because of that data structure, ActiveRecord::Base became a super fat and heavy object. For example, when instantiating 1,000 records having 10 columns, it internally creates 10,000 instances of`Attribute` objects. 214 | This of course causes a massive GC pressure. 215 | 216 | ### The Solution 217 | 218 | This gem defers the creation of attribute objects in a particular use case. 219 | 220 | For the "data transfer object" use case, what we really need is a read-only Struct like object. We don't need interactive type casting feature. We don't need dirty tracking feature. lightweight_attributes focuses on this use case. It's a set of monkey-patches that holds the set of data from DB in a single Hash object rather than an Array of Attribute objects. 221 | 222 | This behavior is base on an assumption that AR model objects from database are "read only" in most cases. There could still be some cases where a model from database receives attribute writes (e.g. for `update` action). And for such cases, our attributes object takes "deoptimization" approach. When this Hash-based model object receives any attribute write, it metamorphses to be a normal attribute-based model object, then falls back to the original Active Record attributes API. 223 | 224 | 225 | ## Contributing 226 | 227 | Pull requests are welcome on GitHub at https://github.com/amatsuda/lightweight_attributes. 228 | 229 | 230 | ## License 231 | 232 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 233 | 234 | 235 | ## Sponsor 236 | 237 | This gem has been developed under total financial support from [Akatsuki Inc.](https://github.com/aktsk). 238 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | Rake::TestTask.new(:test) do |t| 5 | t.libs << "test" 6 | t.libs << "lib" 7 | t.test_files = FileList["test/**/*_test.rb"] 8 | end 9 | 10 | task :default => :test 11 | -------------------------------------------------------------------------------- /benchmark.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # A benchmark script comparing plain Active Record v.s. LightweightAttributes powered Active Record 4 | # 5 | # Usage: 6 | # 7 | # % bundle e ruby benchmark.rb [type] 8 | # 9 | # Options: 10 | # 11 | # There are following three benchmark types: 12 | # 13 | # * memory: memory usage and object allocations (default) 14 | # * time: speed 15 | # * methods: number of method calls 16 | # 17 | # Also, you can give `DB` and `RECORDS` parameters via env var. 18 | # `DB` can be either `postgresql`, `mysql`, or `sqlite3` (defaulted to `mysql`). 19 | # `RECORDS` can be any integer (defaulted to `10000`). 20 | # 21 | # Example: 22 | # 23 | # % DB=postgresql RECORDS=1 bundle e ruby benchmark.rb methods 24 | 25 | require 'rails' 26 | require 'active_record' 27 | require 'active_record/railtie' 28 | require 'memory_profiler' 29 | 30 | ENV['RAILS_ENV'] = 'production' 31 | ENV['DB'] ||= 'mysql' 32 | ENV['RECORDS'] ||= '10000' 33 | 34 | require_relative 'test/dummy_app' 35 | 36 | class Model < ActiveRecord::Base; end 37 | 38 | Class.new(ActiveRecord::VERSION::MAJOR >= 5 ? ActiveRecord::Migration[5.0] : ActiveRecord::Migration) do 39 | def self.up 40 | create_table :models do |t| 41 | t.string :col1; t.string :col2; t.string :col3; t.string :col4; t.string :col5; t.string :col6; t.string :col7; t.string :col8; t.string :col9 42 | end 43 | end 44 | end.up 45 | 46 | ENV['RECORDS'].to_i.times do |i| 47 | Model.create! col1: 'hello, world!', col2: 'hello, world!', col3: 'hello, world!', col4: 'hello, world!', col5: 'hello, world!', col6: 'hello, world!', col7: 'hello, world!', col8: 'hello, world!', col9: 'hello, world!' 48 | end 49 | 50 | #copied from AR 5.2.3 querying.rb 51 | # def find_by_sql(sql, binds = [], preparable: nil, &block) 52 | def measure(benchmarker) 53 | result_set = Model.connection.select_all(Model.all.arel, '', [], preparable: false) 54 | column_types = result_set.column_types.dup 55 | Model.attribute_types.each_key { |k| column_types.delete k } 56 | 57 | # warming up 58 | records = result_set.map {|record| Model.instantiate(record, column_types) } 59 | records.each {|r| v = r.id; v = r.col1; v = r.col2; v = r.col3; v = r.col4; v = r.col5; v = r.col6; v = r.col7; v = r.col8; v = v = r.col9 } 60 | 61 | result = benchmarker.call do 62 | records = result_set.map {|record| Model.instantiate(record, column_types) } 63 | records.each {|r| v = r.id; v = r.col1; v = r.col2; v = r.col3; v = r.col4; v = r.col5; v = r.col6; v = r.col7; v = r.col8; v = v = r.col9 } 64 | end 65 | 66 | result.is_a?(MemoryProfiler::Results) ? result.pretty_print : pp(result) 67 | end 68 | 69 | benchmark = case ARGV[0] 70 | when 'time' 71 | ->(&b) { now = Time.now; b.call; Time.now - now } 72 | when 'methods' 73 | ->(&b) do 74 | [].tap do |methods| 75 | TracePoint.new(:call) {|t| methods << "#{t.defined_class}##{t.method_id}" }.enable { b.call } 76 | end.tally 77 | end 78 | else 79 | ->(&b) { MemoryProfiler.report(&b) } 80 | end 81 | 82 | 83 | GC.start 84 | 85 | puts "#{'*' * 30} ActiveModel::AttributeSet #{'*' * 30}" 86 | 87 | measure benchmark 88 | 89 | GC.start 90 | 91 | puts; puts; puts "#{'*' * 30} LightweightAttributes #{'*' * 30}" 92 | 93 | require_relative 'lib/lightweight_attributes' 94 | require_relative 'lib/lightweight_attributes/base_class_methods' 95 | 96 | Model.instance_variable_set :@attributes_builder, nil 97 | LightweightAttributes::BaseClassMethods.instance_method(:attributes_builder).bind(Model).call 98 | 99 | measure benchmark 100 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "lightweight_attributes" 5 | 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(__FILE__) 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /gemfiles/rails_50.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rails', '~> 5.0.0' 4 | gem 'pg', '~> 0.21' 5 | gem 'sqlite3', '< 1.4' 6 | 7 | gemspec path: '../' 8 | -------------------------------------------------------------------------------- /gemfiles/rails_51.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rails', '~> 5.1.0' 4 | gem 'pg', '~> 0.21' 5 | 6 | gemspec path: '../' 7 | -------------------------------------------------------------------------------- /gemfiles/rails_52.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rails', '~> 5.2.0' 4 | gem 'pg', '~> 0.21' 5 | 6 | gemspec path: '../' 7 | -------------------------------------------------------------------------------- /gemfiles/rails_60.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rails', '~> 6.0.0.rc2' 4 | 5 | gemspec path: '../' 6 | -------------------------------------------------------------------------------- /gemfiles/rails_edge.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | git_source(:github) do |repo_name| 3 | repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?("/") 4 | "https://github.com/#{repo_name}.git" 5 | end 6 | 7 | gem 'rails', github: 'rails/rails' 8 | 9 | gemspec path: '../' 10 | -------------------------------------------------------------------------------- /gemfiles/rails_edge.gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: https://github.com/rails/rails.git 3 | revision: a2a515d9de4ef0ddf4d78b05fcb0b838d2e1b5e3 4 | specs: 5 | actioncable (6.1.0.alpha) 6 | actionpack (= 6.1.0.alpha) 7 | nio4r (~> 2.0) 8 | websocket-driver (>= 0.6.1) 9 | actionmailbox (6.1.0.alpha) 10 | actionpack (= 6.1.0.alpha) 11 | activejob (= 6.1.0.alpha) 12 | activerecord (= 6.1.0.alpha) 13 | activestorage (= 6.1.0.alpha) 14 | activesupport (= 6.1.0.alpha) 15 | mail (>= 2.7.1) 16 | actionmailer (6.1.0.alpha) 17 | actionpack (= 6.1.0.alpha) 18 | actionview (= 6.1.0.alpha) 19 | activejob (= 6.1.0.alpha) 20 | mail (~> 2.5, >= 2.5.4) 21 | rails-dom-testing (~> 2.0) 22 | actionpack (6.1.0.alpha) 23 | actionview (= 6.1.0.alpha) 24 | activesupport (= 6.1.0.alpha) 25 | rack (~> 2.0) 26 | rack-test (>= 0.6.3) 27 | rails-dom-testing (~> 2.0) 28 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 29 | actiontext (6.1.0.alpha) 30 | actionpack (= 6.1.0.alpha) 31 | activerecord (= 6.1.0.alpha) 32 | activestorage (= 6.1.0.alpha) 33 | activesupport (= 6.1.0.alpha) 34 | nokogiri (>= 1.8.5) 35 | actionview (6.1.0.alpha) 36 | activesupport (= 6.1.0.alpha) 37 | builder (~> 3.1) 38 | erubi (~> 1.4) 39 | rails-dom-testing (~> 2.0) 40 | rails-html-sanitizer (~> 1.0, >= 1.0.3) 41 | activejob (6.1.0.alpha) 42 | activesupport (= 6.1.0.alpha) 43 | globalid (>= 0.3.6) 44 | activemodel (6.1.0.alpha) 45 | activesupport (= 6.1.0.alpha) 46 | activerecord (6.1.0.alpha) 47 | activemodel (= 6.1.0.alpha) 48 | activesupport (= 6.1.0.alpha) 49 | activestorage (6.1.0.alpha) 50 | actionpack (= 6.1.0.alpha) 51 | activejob (= 6.1.0.alpha) 52 | activerecord (= 6.1.0.alpha) 53 | marcel (~> 0.3.1) 54 | activesupport (6.1.0.alpha) 55 | concurrent-ruby (~> 1.0, >= 1.0.2) 56 | i18n (>= 0.7, < 2) 57 | minitest (~> 5.1) 58 | tzinfo (~> 1.1) 59 | zeitwerk (~> 2.1, >= 2.1.4) 60 | rails (6.1.0.alpha) 61 | actioncable (= 6.1.0.alpha) 62 | actionmailbox (= 6.1.0.alpha) 63 | actionmailer (= 6.1.0.alpha) 64 | actionpack (= 6.1.0.alpha) 65 | actiontext (= 6.1.0.alpha) 66 | actionview (= 6.1.0.alpha) 67 | activejob (= 6.1.0.alpha) 68 | activemodel (= 6.1.0.alpha) 69 | activerecord (= 6.1.0.alpha) 70 | activestorage (= 6.1.0.alpha) 71 | activesupport (= 6.1.0.alpha) 72 | bundler (>= 1.3.0) 73 | railties (= 6.1.0.alpha) 74 | sprockets-rails (>= 2.0.0) 75 | railties (6.1.0.alpha) 76 | actionpack (= 6.1.0.alpha) 77 | activesupport (= 6.1.0.alpha) 78 | method_source 79 | rake (>= 0.8.7) 80 | thor (>= 0.20.3, < 2.0) 81 | 82 | PATH 83 | remote: .. 84 | specs: 85 | lightweight_attributes (0.2.0) 86 | activerecord (>= 5.0.0) 87 | 88 | GEM 89 | remote: https://rubygems.org/ 90 | specs: 91 | builder (3.2.3) 92 | byebug (11.0.1) 93 | concurrent-ruby (1.1.5) 94 | crass (1.0.4) 95 | erubi (1.8.0) 96 | globalid (0.4.2) 97 | activesupport (>= 4.2.0) 98 | i18n (1.6.0) 99 | concurrent-ruby (~> 1.0) 100 | loofah (2.2.3) 101 | crass (~> 1.0.2) 102 | nokogiri (>= 1.5.9) 103 | mail (2.7.1) 104 | mini_mime (>= 0.1.1) 105 | marcel (0.3.3) 106 | mimemagic (~> 0.3.2) 107 | memory_profiler (0.9.13) 108 | method_source (0.9.2) 109 | mimemagic (0.3.3) 110 | mini_mime (1.0.1) 111 | mini_portile2 (2.4.0) 112 | minitest (5.11.3) 113 | mysql2 (0.5.2) 114 | nio4r (2.3.1) 115 | nokogiri (1.10.3) 116 | mini_portile2 (~> 2.4.0) 117 | pg (1.1.4) 118 | rack (2.0.7) 119 | rack-test (1.1.0) 120 | rack (>= 1.0, < 3) 121 | rails-dom-testing (2.0.3) 122 | activesupport (>= 4.2.0) 123 | nokogiri (>= 1.6) 124 | rails-html-sanitizer (1.0.4) 125 | loofah (~> 2.2, >= 2.2.2) 126 | rake (12.3.2) 127 | sprockets (3.7.2) 128 | concurrent-ruby (~> 1.0) 129 | rack (> 1, < 3) 130 | sprockets-rails (3.2.1) 131 | actionpack (>= 4.0) 132 | activesupport (>= 4.0) 133 | sprockets (>= 3.0.0) 134 | sqlite3 (1.4.1) 135 | thor (0.20.3) 136 | thread_safe (0.3.6) 137 | tzinfo (1.2.5) 138 | thread_safe (~> 0.1) 139 | websocket-driver (0.7.1) 140 | websocket-extensions (>= 0.1.0) 141 | websocket-extensions (0.1.4) 142 | zeitwerk (2.1.6) 143 | 144 | PLATFORMS 145 | ruby 146 | 147 | DEPENDENCIES 148 | bundler 149 | byebug 150 | lightweight_attributes! 151 | memory_profiler 152 | minitest 153 | mysql2 154 | pg 155 | rails! 156 | rake 157 | sqlite3 158 | 159 | BUNDLED WITH 160 | 2.0.1 161 | -------------------------------------------------------------------------------- /lib/lightweight_attributes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lightweight_attributes/railtie' 4 | require_relative 'lightweight_attributes/attribute_set' 5 | -------------------------------------------------------------------------------- /lib/lightweight_attributes/attribute_set.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'attribute_set/builder' 4 | 5 | module LightweightAttributes 6 | class AttributeSet 7 | attr_reader :raw_attributes, :additional_types 8 | 9 | delegate :each_value, :fetch, :except, :[], :[]=, to: :@attributes 10 | delegate :key?, :keys, to: :@raw_attributes 11 | 12 | def initialize(raw_attributes, types, additional_types) 13 | @raw_attributes = raw_attributes 14 | @types = types 15 | @additional_types = additional_types 16 | @attributes = {} 17 | def @attributes.delegate_hash 18 | self 19 | end 20 | @sorted = true 21 | end 22 | 23 | # Lazily cast the attribute from database when being fetched. 24 | # For String attributes, directly returns the raw value without creating a new copy. 25 | def fetch_value(name) 26 | return @attributes[name] if @attributes.key? name 27 | 28 | @sorted = false unless @attributes.empty? 29 | type = @types[name] 30 | @attributes[name] = ActiveModel::Type::String === type ? @raw_attributes[name] : type.deserialize(@raw_attributes[name]) 31 | end 32 | 33 | # For Model#accessed_fields 34 | def accessed 35 | sort_attributes!.keys 36 | end 37 | 38 | # Cast all attributes, then return the result. 39 | def to_hash 40 | @raw_attributes.each do |k, v| 41 | unless @attributes.key? k 42 | @sorted = false unless @attributes.empty? 43 | type = @types[k] 44 | @attributes[k] = ActiveModel::Type::String === type ? @raw_attributes[k] : type.deserialize(v) 45 | end 46 | end 47 | 48 | sort_attributes! 49 | end 50 | 51 | def reset(key) 52 | @attributes[key] = nil 53 | end 54 | 55 | # ZOMG: This object can never be frozen!!! 56 | def freeze 57 | self 58 | end 59 | 60 | private 61 | 62 | attr_reader :attributes 63 | 64 | def sort_attributes! 65 | return @attributes if @sorted 66 | 67 | @sorted = true 68 | @attributes = @raw_attributes.each_key.with_object({}) do |k, h| 69 | h[k] = @attributes[k] if @attributes.key? k 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/lightweight_attributes/attribute_set/builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LightweightAttributes 4 | class AttributeSet 5 | class Builder 6 | attr_reader :types, :default_attributes 7 | 8 | def initialize(types, default_attributes, original_attributes_builder) 9 | @types = types 10 | @default_attributes = default_attributes 11 | @original_attributes_builder = original_attributes_builder 12 | end 13 | 14 | # Build our own lightweight attribute set. 15 | def build_from_database(values, _additional_types) 16 | LightweightAttributes::AttributeSet.new values, @types, _additional_types 17 | end 18 | 19 | def build_original_from_database(values, additional_types) 20 | @original_attributes_builder.build_from_database values, additional_types 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/lightweight_attributes/base_class_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'base_methods' 4 | 5 | module LightweightAttributes 6 | module BaseClassMethods 7 | # Overriding AR class method to return our custom attributes_builder only when the model has no custom attributes. 8 | def attributes_builder 9 | if attributes_to_define_after_schema_loads.empty? 10 | unless defined?(@attributes_builder) && @attributes_builder 11 | original_attributes_builder = super 12 | 13 | defaults = _default_attributes.except(*(column_names - [primary_key])) 14 | @attributes_builder = LightweightAttributes::AttributeSet::Builder.new(attribute_types, defaults, original_attributes_builder) 15 | end 16 | @attributes_builder 17 | else 18 | super 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/lightweight_attributes/base_methods.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LightweightAttributes 4 | module BaseMethods 5 | # LightweightAttributes does not accept write_attribute. 6 | # If any write_attribute attempt was made, it switches the whole attributes instance to the AR default attributes. 7 | if (::ActiveRecord::VERSION::MAJOR == 5) && (::ActiveRecord::VERSION::MINOR < 2) 8 | def write_attribute(*) 9 | if LightweightAttributes::AttributeSet === @attributes 10 | @attributes = self.class.attributes_builder.build_original_from_database @attributes.raw_attributes, @attributes.additional_types 11 | end 12 | 13 | super 14 | end 15 | 16 | def raw_write_attribute(*) 17 | if LightweightAttributes::AttributeSet === @attributes 18 | @attributes = self.class.attributes_builder.build_original_from_database @attributes.raw_attributes, @attributes.additional_types 19 | end 20 | 21 | super 22 | end 23 | else 24 | def _write_attribute(*) 25 | if LightweightAttributes::AttributeSet === @attributes 26 | @attributes = self.class.attributes_builder.build_original_from_database @attributes.raw_attributes, @attributes.additional_types 27 | end 28 | 29 | super 30 | end 31 | end 32 | 33 | def read_attribute_before_type_cast(attr_name) 34 | if LightweightAttributes::AttributeSet === @attributes 35 | @attributes.raw_attributes[attr_name.to_s] 36 | else 37 | super 38 | end 39 | end 40 | 41 | def attributes_before_type_cast 42 | if LightweightAttributes::AttributeSet === @attributes 43 | @attributes.raw_attributes 44 | else 45 | super 46 | end 47 | end 48 | 49 | private 50 | 51 | # LightweightAttributes does not support dirty tracking. 52 | # If any dirty tracking attempt was made, it switches the whole attributes instance to the AR default attributes. 53 | if (::ActiveRecord::VERSION::MAJOR == 5) && (::ActiveRecord::VERSION::MINOR < 2) 54 | def mutation_tracker 55 | if LightweightAttributes::AttributeSet === @attributes 56 | @attributes = self.class.attributes_builder.build_original_from_database @attributes.raw_attributes, @attributes.additional_types 57 | end 58 | 59 | super 60 | end 61 | else 62 | def mutations_from_database 63 | if LightweightAttributes::AttributeSet === @attributes 64 | @attributes = self.class.attributes_builder.build_original_from_database @attributes.raw_attributes, @attributes.additional_types 65 | end 66 | 67 | super 68 | end 69 | end 70 | 71 | def attribute_came_from_user?(*) 72 | if LightweightAttributes::AttributeSet === @attributes 73 | false 74 | else 75 | super 76 | end 77 | end 78 | 79 | # lightweight_attributes doesn't know anything about assignments already. 80 | if (::ActiveRecord::VERSION::MAJOR == 5) && (::ActiveRecord::VERSION::MINOR == 0) 81 | def store_original_attributes 82 | super unless LightweightAttributes::AttributeSet === @attributes 83 | end 84 | else 85 | def forget_attribute_assignments 86 | super unless LightweightAttributes::AttributeSet === @attributes 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/lightweight_attributes/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module LightweightAttributes 4 | class Railtie < ::Rails::Railtie 5 | initializer 'lightweight_attributes' do 6 | require_relative 'base_class_methods' 7 | 8 | ActiveSupport.on_load :active_record do 9 | extend LightweightAttributes::BaseClassMethods 10 | prepend LightweightAttributes::BaseMethods 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lightweight_attributes.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | Gem::Specification.new do |spec| 5 | spec.name = "lightweight_attributes" 6 | spec.version = '0.2.0'.freeze 7 | spec.authors = ["Akira Matsuda"] 8 | spec.email = ["ronnie@dio.jp"] 9 | 10 | spec.summary = 'Good old lightweight attributes for Active Record' 11 | spec.description = 'Bring the speed back to your Active Record models!' 12 | spec.homepage = 'https://github.com/amatsuda/lightweight_attributes' 13 | spec.license = "MIT" 14 | 15 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 16 | f.match(%r{^(test|spec|features)/}) 17 | end 18 | spec.bindir = "exe" 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_dependency 'activerecord', '>= 5.0.0' 23 | spec.add_development_dependency 'rails', '>= 5.0.0' 24 | spec.add_development_dependency "bundler" 25 | spec.add_development_dependency "rake" 26 | spec.add_development_dependency "minitest" 27 | spec.add_development_dependency 'sqlite3' 28 | spec.add_development_dependency 'mysql2' 29 | spec.add_development_dependency 'pg' 30 | spec.add_development_dependency 'byebug' 31 | spec.add_development_dependency 'memory_profiler' 32 | end 33 | -------------------------------------------------------------------------------- /test/config/database.yml: -------------------------------------------------------------------------------- 1 | <% case ENV['DB'] 2 | when 'sqlite3' %> 3 | test: &test 4 | adapter: sqlite3 5 | database: db/lightweight_attributes_test.sqlite3 6 | pool: 5 7 | timeout: 5000 8 | 9 | <% when 'mysql' %> 10 | test: &test 11 | adapter: mysql2 12 | host: localhost 13 | username: root 14 | password: 15 | database: lightweight_attributes_test 16 | 17 | <% when 'postgresql' %> 18 | test: &test 19 | adapter: postgresql 20 | host: localhost 21 | username: postgres 22 | password: 23 | database: lightweight_attributes_test 24 | <% end %> 25 | 26 | production: 27 | <<: *test 28 | -------------------------------------------------------------------------------- /test/db/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amatsuda/lightweight_attributes/671b4131eb80f8d3c7e46b5d0252c638dc3b59fb/test/db/.keep -------------------------------------------------------------------------------- /test/dummy_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module DummyApp 4 | Application = Class.new(Rails::Application) do 5 | config.eager_load = false 6 | config.active_support.deprecation = :log 7 | config.root = __dir__ 8 | end.initialize! 9 | end 10 | 11 | ActiveRecord::Migration.verbose = false 12 | 13 | ActiveRecord::Tasks::DatabaseTasks.drop_current 'test' 14 | ActiveRecord::Tasks::DatabaseTasks.create_current 'test' 15 | -------------------------------------------------------------------------------- /test/lightweight_attributes_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class LightweightAttributesTest < Minitest::Test 6 | class PostForInsert < ActiveRecord::Base 7 | self.table_name = 'posts' 8 | end 9 | 10 | def setup 11 | super 12 | Object.const_set :Post, Class.new(ActiveRecord::Base) 13 | end 14 | 15 | def teardown 16 | super 17 | ActiveRecord::Base.clear_all_connections! 18 | Object.send :remove_const, :Post 19 | PostForInsert.reset_column_information 20 | end 21 | 22 | def with_attributes(*attrs) 23 | migration = Class.new(ActiveRecord::VERSION::MAJOR >= 5 ? ActiveRecord::Migration[5.0] : ActiveRecord::Migration) do 24 | define_singleton_method :up do 25 | create_table :posts 26 | attrs.each do |name, type, options| 27 | add_column :posts, name, type 28 | end 29 | end 30 | 31 | def self.down 32 | drop_table :posts 33 | end 34 | end 35 | 36 | migration.up 37 | yield 38 | ensure 39 | migration.down 40 | end 41 | 42 | def assert_lightweight_attributes(model) 43 | assert_instance_of LightweightAttributes::AttributeSet, model.instance_variable_get(:@attributes) 44 | end 45 | 46 | def assert_not_lightweight_attributes(model) 47 | refute_instance_of LightweightAttributes::AttributeSet, model.instance_variable_get(:@attributes) 48 | end 49 | 50 | def test_new 51 | with_attributes [:title, :string], [:body, :text], [:posted_at, :datetime], [:category, :integer], [:published, :boolean] do 52 | p = Post.new 53 | assert_not_lightweight_attributes p 54 | end 55 | end 56 | 57 | def test_reader 58 | with_attributes [:title, :string], [:body, :text], [:posted_at, :datetime], [:category, :integer], [:published, :boolean] do 59 | now = Time.current.change(usec: 0) 60 | PostForInsert.create! title: 'hello', body: 'world', posted_at: now.to_s(:db), category: 123, published: true 61 | 62 | p = Post.last 63 | assert_lightweight_attributes p 64 | assert_equal 'hello', p.title 65 | assert_equal 'world', p.body 66 | assert_equal now, p.posted_at 67 | assert_equal 123, p.category 68 | assert_equal true, p.published 69 | end 70 | end 71 | 72 | def test_writer 73 | with_attributes [:title, :string] do 74 | PostForInsert.create! title: 'hello' 75 | 76 | p = Post.last 77 | assert_lightweight_attributes p 78 | 79 | p.title = 'updated!' 80 | assert_equal 'updated!', p.title 81 | assert_not_lightweight_attributes p 82 | end 83 | end 84 | 85 | def test_raw_writer 86 | with_attributes [:title, :string] do 87 | PostForInsert.create! title: 'hello' 88 | 89 | p = Post.last 90 | assert_lightweight_attributes p 91 | 92 | p.update_columns(title: 'goodbye') 93 | assert_equal 'goodbye', p.title 94 | assert_not_lightweight_attributes p 95 | end 96 | end 97 | 98 | def test_dirty 99 | with_attributes [:title, :string] do 100 | PostForInsert.create! title: 'hello' 101 | 102 | p = Post.last 103 | assert_lightweight_attributes p 104 | 105 | assert_equal 'hello', p.title_was 106 | assert_not_lightweight_attributes p 107 | end 108 | end 109 | 110 | def test_save 111 | with_attributes [:title, :string] do 112 | PostForInsert.create! title: 'hello' 113 | 114 | p = Post.last 115 | assert_lightweight_attributes p 116 | 117 | assert_equal true, p.save 118 | assert_not_lightweight_attributes p 119 | end 120 | end 121 | 122 | def test_came_from_user 123 | with_attributes [:title, :string] do 124 | PostForInsert.create! title: 'hello' 125 | 126 | p = Post.last 127 | assert_lightweight_attributes p 128 | 129 | assert_equal false, p.title_came_from_user? 130 | assert_lightweight_attributes p 131 | end 132 | end 133 | 134 | def test_to_hash 135 | with_attributes [:title, :string] do 136 | PostForInsert.create! title: 'hello' 137 | 138 | p = Post.last 139 | assert_lightweight_attributes p 140 | 141 | title = p.title 142 | id = p.id 143 | assert_equal({'id' => id, 'title' => title}, p.attributes.to_hash) 144 | assert_equal(%w(id title), p.attributes.to_hash.keys) 145 | end 146 | end 147 | 148 | def test_accessed_fields 149 | with_attributes [:title, :string] do 150 | PostForInsert.create! title: 'hello' 151 | 152 | p = Post.last 153 | assert_lightweight_attributes p 154 | 155 | assert_equal [], p.accessed_fields 156 | _ = p.title 157 | assert_equal %w(title), p.accessed_fields 158 | _ = p.id 159 | assert_equal %w(id title), p.accessed_fields 160 | end 161 | end 162 | 163 | def test_attributes_before_type_cast 164 | with_attributes [:title, :string] do 165 | PostForInsert.create! title: 'hello' 166 | 167 | p = Post.last 168 | assert_lightweight_attributes p 169 | 170 | assert_equal({'id' => 1, 'title' => 'hello'}, p.attributes_before_type_cast) 171 | assert_lightweight_attributes p 172 | end 173 | end 174 | 175 | def test_read_attribute_before_type_cast 176 | with_attributes [:title, :string] do 177 | PostForInsert.create! title: 'hello' 178 | 179 | p = Post.last 180 | assert_lightweight_attributes p 181 | 182 | assert_equal 'hello', p.read_attribute_before_type_cast(:title) 183 | assert_lightweight_attributes p 184 | end 185 | end 186 | 187 | def test_clear_changes_information 188 | with_attributes [:title, :string] do 189 | PostForInsert.create! title: 'hello' 190 | 191 | p = Post.last 192 | assert_lightweight_attributes p 193 | 194 | assert_nil p.clear_changes_information 195 | assert_lightweight_attributes p 196 | end 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../../lib", __FILE__) 4 | require 'rails' 5 | require "lightweight_attributes" 6 | require 'active_record' 7 | require 'active_record/railtie' 8 | 9 | require "minitest/autorun" 10 | require 'byebug' 11 | 12 | ENV['RAILS_ENV'] = 'test' 13 | ENV['DB'] ||= 'sqlite3' 14 | 15 | require_relative 'dummy_app' 16 | --------------------------------------------------------------------------------