├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── Appraisals ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── gemfiles ├── activerecord_4.0.gemfile ├── activerecord_4.1.gemfile ├── activerecord_4.2.gemfile ├── activerecord_5.0.gemfile └── activerecord_5.1.gemfile ├── hstore_accessor.gemspec ├── lib ├── hstore_accessor.rb └── hstore_accessor │ ├── active_record_4.2 │ └── type_helpers.rb │ ├── active_record_5.0 │ └── type_helpers.rb │ ├── active_record_pre_4.2 │ ├── time_helper.rb │ └── type_helpers.rb │ ├── macro.rb │ ├── serialization.rb │ └── version.rb └── spec ├── hstore_accessor_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | logfile 19 | gemfiles/*.gemfile.lock 20 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Include: 3 | - Rakefile 4 | Lint/SpaceBeforeFirstArg: 5 | Enabled: false 6 | Lint/UnusedBlockArgument: 7 | Enabled: false 8 | Lint/UnusedMethodArgument: 9 | Enabled: false 10 | Metrics/AbcSize: 11 | Enabled: false 12 | Metrics/ClassLength: 13 | Enabled: false 14 | Metrics/CyclomaticComplexity: 15 | Enabled: false 16 | Metrics/LineLength: 17 | Enabled: false 18 | Metrics/MethodLength: 19 | Enabled: false 20 | Metrics/PerceivedComplexity: 21 | Enabled: false 22 | Style/AlignParameters: 23 | Enabled: false 24 | Style/ClassAndModuleChildren: 25 | Enabled: false 26 | Style/ClassVars: 27 | Enabled: false 28 | Style/Documentation: 29 | Enabled: false 30 | Style/FileName: 31 | Enabled: false 32 | Style/GuardClause: 33 | Enabled: false 34 | Style/IndentHash: 35 | Enabled: false 36 | Style/RescueModifier: 37 | Enabled: false 38 | Style/SignalException: 39 | Enabled: false 40 | Style/SpaceAroundEqualsInParameterDefault: 41 | Enabled: false 42 | Style/StringLiterals: 43 | EnforcedStyle: double_quotes 44 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.3.3 2 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "activerecord-5.1" do 2 | gem "activerecord", "~> 5.1.0" 3 | end 4 | 5 | appraise "activerecord-5.0" do 6 | gem "activerecord", "~> 5.0.0" 7 | end 8 | 9 | appraise "activerecord-4.2" do 10 | gem "activerecord", "~> 4.2.0" 11 | end 12 | 13 | appraise "activerecord-4.1" do 14 | gem "activerecord", "~> 4.1.0" 15 | end 16 | 17 | appraise "activerecord-4.0" do 18 | gem "activerecord", "~> 4.0.0" 19 | end 20 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :test do 6 | gem "pg", ">= 0.14.1" 7 | end 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 JC Grubbs 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HstoreAccessor (Deprecated) 2 | 3 | ## Please note that this repo is deprecated and is no longer being maintained. 4 | **Use [Jsonb Accessor](https://github.com/devmynd/jsonb_accessor) instead! It has more features and is better maintained.** 5 | 6 | ## Description 7 | Hstore Accessor allows you to treat fields on an hstore column as though they were actual columns being picked up by ActiveRecord. This is especially handy when trying to avoid sparse columns while making use of [single table inheritence](#single-table-inheritance). Hstore Accessor currently supports ActiveRecord versions 4.0, 4.1, 4.2, 5.0, and 5.1. 8 | 9 | 10 | ## Table of Contents 11 | 12 | - [HstoreAccessor (Deprecated)](#hstoreaccessor-deprecated) 13 | - [Please note that this repo is deprecated and is no longer being maintained.](#please-note-that-this-repo-is-deprecated-and-is-no-longer-being-maintained) 14 | - [Description](#description) 15 | - [Table of Contents](#table-of-contents) 16 | - [Installation](#installation) 17 | - [Setup](#setup) 18 | - [ActiveRecord methods generated for fields](#activerecord-methods-generated-for-fields) 19 | - [Scopes](#scopes) 20 | - [String Fields](#string-fields) 21 | - [Integer, Float, Decimal Fields](#integer-float-decimal-fields) 22 | - [Datetime Fields](#datetime-fields) 23 | - [Date Fields](#date-fields) 24 | - [Array Fields](#array-fields) 25 | - [Boolean Fields](#boolean-fields) 26 | - [Single-table Inheritance](#single-table-inheritance) 27 | - [Upgrading](#upgrading) 28 | - [Contributing](#contributing) 29 | - [Basics](#basics) 30 | - [Developing Locally](#developing-locally) 31 | 32 | ## Installation 33 | 34 | Add this line to your application's Gemfile: 35 | 36 | ```ruby 37 | gem "hstore_accessor", "~> 1.1" 38 | ``` 39 | 40 | And then execute: 41 | 42 | $ bundle 43 | 44 | Or install it yourself as: 45 | 46 | $ gem install hstore_accessor 47 | 48 | ## Setup 49 | 50 | The `hstore_accessor` method accepts the name of the hstore column you'd 51 | like to use and a hash with keys representing fields and values 52 | indicating the type to be stored in that field. The available types 53 | are: `string`, `integer`, `float`, `decimal`, `datetime`, `date`, `boolean`, `array`, and `hash`. It is available on an class that inherits from `ActiveRecord::Base`. 54 | 55 | ```ruby 56 | class Product < ActiveRecord::Base 57 | hstore_accessor :options, 58 | color: :string, 59 | weight: :integer, 60 | price: :float, 61 | built_at: :datetime, 62 | build_date: :date, 63 | tags: :array, # deprecated 64 | ratings: :hash # deprecated 65 | miles: :decimal 66 | end 67 | ``` 68 | 69 | Now you can interact with the fields stored in the hstore directly. 70 | 71 | ```ruby 72 | product = Product.new 73 | product.color = "green" 74 | product.weight = 34 75 | product.price = 99.95 76 | product.built_at = Time.now - 10.days 77 | product.build_date = Date.today 78 | product.popular = true 79 | product.tags = %w(housewares kitchen) # deprecated 80 | product.ratings = { user_a: 3, user_b: 4 } # deprecated 81 | product.miles = 3.14 82 | ``` 83 | 84 | Reading these fields works as well. 85 | 86 | ```ruby 87 | product.color # => "green" 88 | product.price # => 99.95 89 | ``` 90 | 91 | In order to reduce the storage overhead of hstore keys (especially when 92 | indexed) you can specify an alternate key. 93 | 94 | ```ruby 95 | hstore_accessor :options, 96 | color: { data_type: :string, store_key: "c" }, 97 | weight: { data_type: :integer, store_key: "w" } 98 | ``` 99 | 100 | In the above example you can continue to interact with the fields using 101 | their full name but when saved to the database the field will be set 102 | using the `store_key`. 103 | 104 | Additionally, dirty tracking is implemented in the same way that normal 105 | `ActiveRecord` fields work. 106 | 107 | ```ruby 108 | product.color #=> "green" 109 | product.color = "blue" 110 | product.changed? #=> true 111 | product.color_changed? #=> true 112 | product.color_was #=> "green" 113 | product.color_change #=> ["green", "blue"] 114 | ``` 115 | 116 | ## ActiveRecord methods generated for fields 117 | 118 | ```ruby 119 | class Product < ActiveRecord::Base 120 | hstore_accessor :data, field: :string 121 | end 122 | ``` 123 | 124 | * `field` 125 | * `field=` 126 | * `field?` 127 | * `field_changed?` 128 | * `field_was` 129 | * `field_change` 130 | * `reset_field!` 131 | * `restore_field!` 132 | * `field_will_change!` 133 | 134 | Overriding methods is supported, with access to the original Hstore Accessor implementation available via `super`. 135 | 136 | Additionally, there is also `hstore_metadata_for_` on both the class and instances. `column_for_attribute` will also return a column object for an Hstore Accessor defined field. If you're using ActiveRecord 4.2, `type_for_attribute` will return a type object for Hstore Accessor defined fields the same as it does for actual columns. 137 | 138 | ## Scopes 139 | 140 | The `hstore_accessor` macro also creates scopes for `string`, `integer`, 141 | `float`, `decimal`, `time`, `date`, `boolean`, and `array` fields. 142 | 143 | ### String Fields 144 | 145 | For `string` types, a `with_` scope is created which checks for 146 | equality. 147 | 148 | ```ruby 149 | Product.with_color("green") 150 | ``` 151 | 152 | ### Integer, Float, Decimal Fields 153 | 154 | For `integer`, `float` and `decimal` types five scopes are created: 155 | 156 | ```ruby 157 | Product.price_lt(240.00) # price less than 158 | Product.price_lte(240.00) # price less than or equal to 159 | Product.price_eq(240.00) # price equal to 160 | Product.price_gte(240.00) # price greater than or equal to 161 | Product.price_gt(240.00) # price greater than 162 | ``` 163 | 164 | ### Datetime Fields 165 | 166 | For `datetime` fields, three scopes are created: 167 | 168 | ```ruby 169 | Product.built_at_before(Time.now) # built before the given time 170 | Product.built_at_eq(Time.now - 10.days) # built at an exact time 171 | Product.built_at_after(Time.now - 4.days) # built after the given time 172 | ``` 173 | 174 | ### Date Fields 175 | 176 | For `date` fields, three scopes are created: 177 | 178 | ```ruby 179 | Product.build_date_before(Date.today) # built before the given date 180 | Product.build_date_eq(Date.today - 10.days) # built at an exact date 181 | Product.built_date_after(Date.today - 4.days) # built after the given date 182 | ``` 183 | 184 | ### Array Fields 185 | 186 | *Note: the array field type is deprecated. It is available in version 0.9.0 but not > 1.0.0* 187 | 188 | For `array` types, two scopes are created: 189 | 190 | ```ruby 191 | Product.tags_eq(%w(housewares kitchen)) # tags equaling 192 | Product.tags_contains("kitchen") # tags containing a single value 193 | Product.tags_contains(%w(housewares kitchen)) # tags containing a number of values 194 | ``` 195 | 196 | ### Boolean Fields 197 | 198 | Two scopes are created for `boolean` fields: 199 | 200 | ```ruby 201 | Product.is_popular # => when popular is set to true 202 | Product.not_popular # => when popular is set to false 203 | ``` 204 | 205 | Predicate methods are also available on instances: 206 | 207 | ```ruby 208 | product = Product.new(popular: true) 209 | product.popular? # => true 210 | ``` 211 | 212 | ### Single-table Inheritance 213 | 214 | One of the big issues with `ActiveRecord` single-table inheritance (STI) 215 | is sparse columns. Essentially, as sub-types of the original table 216 | diverge further from their parent more columns are left empty in a given 217 | table. Postgres' `hstore` type provides part of the solution in that 218 | the values in an `hstore` column does not impose a structure - different 219 | rows can have different values. 220 | 221 | We set up our table with an hstore field: 222 | 223 | ```ruby 224 | # db/migration/_create_players_table.rb 225 | class CreateVehiclesTable < ActiveRecord::Migration 226 | def change 227 | create_table :vehicles do |t| 228 | t.string :make 229 | t.string :model 230 | t.integer :model_year 231 | t.string :type 232 | t.hstore :data 233 | end 234 | end 235 | end 236 | ``` 237 | 238 | And for our models: 239 | 240 | ```ruby 241 | # app/models/vehicle.rb 242 | class Vehicle < ActiveRecord::Base 243 | end 244 | 245 | # app/models/vehicles/automobile.rb 246 | class Automobile < Vehicle 247 | hstore_accessor :data, 248 | axle_count: :integer, 249 | weight: :float, 250 | engine_details: :hash 251 | end 252 | 253 | # app/models/vehicles/airplane.rb 254 | class Airplane < Vehicle 255 | hstore_accessor :data, 256 | engine_type: :string, 257 | safety_rating: :integer, 258 | features: :hash 259 | end 260 | ``` 261 | 262 | From here any attributes specific to any sub-class can be stored in the 263 | `hstore` column avoiding sparse data. Indices can also be created on 264 | individual fields in an `hstore` column. 265 | 266 | This approach was originally concieved by Joe Hirn in [this blog 267 | post](http://www.devmynd.com/blog/2013-3-single-table-inheritance-hstore-lovely-combination). 268 | 269 | ## Upgrading 270 | Upgrading from version 0.6.0 to 0.9.0 should be fairly painless. If you were previously using a `time` type fields, simply change it to `datetime` like so: 271 | 272 | ```ruby 273 | # Before... 274 | hstore_accessor :data, published_at: :time 275 | # After... 276 | hstore_accessor :data, published_at: :datetime 277 | ``` 278 | 279 | While the `array` and `hash` types are available in version 0.9.0, they are deprecated and are not available in 1.0.0. 280 | 281 | ## Contributing 282 | ### Basics 283 | 1. [Fork it](https://github.com/devmynd/hstore_accessor/fork) 284 | 2. Create your feature branch (`git checkout -b my-new-feature`) 285 | 3. Write code _and_ tests 286 | 4. Commit your changes (`git commit -am 'Add some feature'`) 287 | 5. Push to the branch (`git push origin my-new-feature`) 288 | 6. Create new Pull Request 289 | 290 | ### Developing Locally 291 | Before you make your pull requests, please make sure you style is in line with our Rubocop settings and that all of the tests pass. 292 | 293 | 1. `bundle install` 294 | 2. `appraisal install` 295 | 3. Make sure Postgres is installed and running 296 | 4. `appraisal rspec` to run all the tests 297 | 5. `rubocop` to check for style 298 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rubygems" 2 | require "bundler/setup" 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | require "rubocop/rake_task" 6 | 7 | RSpec::Core::RakeTask.new 8 | RuboCop::RakeTask.new 9 | 10 | task(default: [:rubocop, :spec]) 11 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 4.0.0" 6 | 7 | group :test do 8 | gem "pg", ">= 0.14.1" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 4.1.0" 6 | 7 | group :test do 8 | gem "pg", ">= 0.14.1" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/activerecord_4.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 4.2.0" 6 | 7 | group :test do 8 | gem "pg", ">= 0.14.1" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.0.0" 6 | 7 | group :test do 8 | gem "pg", ">= 0.14.1" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /gemfiles/activerecord_5.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.1.0" 6 | 7 | group :test do 8 | gem "pg", ">= 0.14.1" 9 | end 10 | 11 | gemspec path: "../" 12 | -------------------------------------------------------------------------------- /hstore_accessor.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "hstore_accessor/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "hstore_accessor" 8 | spec.version = HstoreAccessor::VERSION 9 | spec.authors = ["Joe Hirn", "Cory Stephenson", "JC Grubbs", "Tony Coconate", "Michael Crismali"] 10 | spec.email = ["joe@devmynd.com", "cory@devmynd.com", "jc@devmynd.com", "me@tonycoconate.com", "michael@devmynd.com"] 11 | spec.description = "Adds typed hstore backed fields to an ActiveRecord model." 12 | spec.summary = "Adds typed hstore backed fields to an ActiveRecord model." 13 | spec.homepage = "http://github.com/devmynd/hstore_accessor" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files`.split($/) 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency "activerecord", ">= 4.0.0" 22 | 23 | spec.add_development_dependency "appraisal" 24 | spec.add_development_dependency "bundler", "~> 1.7" 25 | spec.add_development_dependency "database_cleaner" 26 | spec.add_development_dependency "pry" 27 | spec.add_development_dependency "pry-doc" 28 | spec.add_development_dependency "pry-nav" 29 | spec.add_development_dependency "rake", "< 11.0" 30 | spec.add_development_dependency "rspec", "~> 3.1.0" 31 | spec.add_development_dependency "rubocop" 32 | spec.add_development_dependency "shoulda-matchers", "~> 3.1" 33 | 34 | spec.post_install_message = "Please note that the `array` and `hash` types are no longer supported in version 1.0.0" 35 | end 36 | -------------------------------------------------------------------------------- /lib/hstore_accessor.rb: -------------------------------------------------------------------------------- 1 | require "active_support" 2 | require "active_record" 3 | require "hstore_accessor/version" 4 | 5 | if ::ActiveRecord::VERSION::STRING.to_f >= 5.0 6 | require "hstore_accessor/active_record_5.0/type_helpers" 7 | elsif ::ActiveRecord::VERSION::STRING.to_f >= 4.2 && ::ActiveRecord::VERSION::STRING.to_f < 5.0 8 | require "hstore_accessor/active_record_4.2/type_helpers" 9 | else 10 | require "hstore_accessor/active_record_pre_4.2/type_helpers" 11 | require "hstore_accessor/active_record_pre_4.2/time_helper" 12 | end 13 | 14 | require "hstore_accessor/serialization" 15 | require "hstore_accessor/macro" 16 | require "bigdecimal" 17 | 18 | module HstoreAccessor 19 | extend ActiveSupport::Concern 20 | include Serialization 21 | include Macro 22 | end 23 | 24 | ActiveSupport.on_load(:active_record) do 25 | ActiveRecord::Base.send(:include, HstoreAccessor) 26 | end 27 | -------------------------------------------------------------------------------- /lib/hstore_accessor/active_record_4.2/type_helpers.rb: -------------------------------------------------------------------------------- 1 | module HstoreAccessor 2 | module TypeHelpers 3 | TYPES = { 4 | boolean: ActiveRecord::Type::Boolean, 5 | date: ActiveRecord::Type::Date, 6 | datetime: ActiveRecord::Type::DateTime, 7 | decimal: ActiveRecord::Type::Decimal, 8 | float: ActiveRecord::Type::Float, 9 | integer: ActiveRecord::Type::Integer, 10 | string: ActiveRecord::Type::String 11 | } 12 | 13 | TYPES.default = ActiveRecord::Type::Value 14 | 15 | class << self 16 | def column_type_for(attribute, data_type) 17 | ActiveRecord::ConnectionAdapters::Column.new(attribute.to_s, nil, TYPES[data_type].new) 18 | end 19 | 20 | def cast(type, value) 21 | return nil if value.nil? 22 | 23 | case type 24 | when :string, :decimal 25 | value 26 | when :integer, :float, :datetime, :date, :boolean 27 | TYPES[type].new.type_cast_from_user(value) 28 | else value 29 | # Nothing. 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/hstore_accessor/active_record_5.0/type_helpers.rb: -------------------------------------------------------------------------------- 1 | module HstoreAccessor 2 | module TypeHelpers 3 | TYPES = { 4 | boolean: ActiveRecord::Type::Boolean, 5 | date: ActiveRecord::Type::Date, 6 | datetime: ActiveRecord::Type::DateTime, 7 | decimal: ActiveRecord::Type::Decimal, 8 | float: ActiveRecord::Type::Float, 9 | integer: ActiveRecord::Type::Integer, 10 | string: ActiveRecord::Type::String 11 | } 12 | 13 | TYPES.default = ActiveRecord::Type::Value 14 | 15 | class << self 16 | def column_type_for(attribute, data_type) 17 | ActiveRecord::ConnectionAdapters::Column.new(attribute.to_s, nil, TYPES[data_type].new) 18 | end 19 | 20 | def cast(type, value) 21 | return nil if value.nil? 22 | 23 | case type 24 | when :string, :decimal 25 | value 26 | when :integer, :float, :datetime, :date, :boolean 27 | TYPES[type].new.cast(value) 28 | else value 29 | # Nothing. 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/hstore_accessor/active_record_pre_4.2/time_helper.rb: -------------------------------------------------------------------------------- 1 | module HstoreAccessor 2 | module TimeHelper 3 | # There is a bug in ActiveRecord::ConnectionAdapters::Column#string_to_time 4 | # which drops the timezone. This has been fixed, but not released. 5 | # This method includes the fix. See: https://github.com/rails/rails/pull/12290 6 | 7 | def self.string_to_time(string) 8 | return string unless string.is_a?(String) 9 | return nil if string.empty? 10 | 11 | time_hash = Date._parse(string) 12 | time_hash[:sec_fraction] = ActiveRecord::ConnectionAdapters::Column.send(:microseconds, time_hash) 13 | year, mon, mday, hour, min, sec, microsec, offset = time_hash.values_at(:year, :mon, :mday, :hour, :min, :sec, :sec_fraction, :offset) 14 | 15 | # Treat 0000-00-00 00:00:00 as nil. 16 | return nil if year.nil? || [year, mon, mday].all?(&:zero?) 17 | 18 | if offset 19 | time = Time.utc(year, mon, mday, hour, min, sec, microsec) rescue nil 20 | return nil unless time 21 | 22 | time -= offset 23 | ActiveRecord::Base.default_timezone == :utc ? time : time.getlocal 24 | else 25 | Time.public_send(ActiveRecord::Base.default_timezone, year, mon, mday, hour, min, sec, microsec) rescue nil 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/hstore_accessor/active_record_pre_4.2/type_helpers.rb: -------------------------------------------------------------------------------- 1 | module HstoreAccessor 2 | module TypeHelpers 3 | TYPES = { 4 | string: "char", 5 | datetime: "datetime", 6 | date: "date", 7 | float: "float", 8 | boolean: "boolean", 9 | decimal: "decimal", 10 | integer: "int" 11 | } 12 | 13 | class << self 14 | def column_type_for(attribute, data_type) 15 | ActiveRecord::ConnectionAdapters::Column.new(attribute.to_s, nil, TYPES[data_type]) 16 | end 17 | 18 | def cast(type, value) 19 | return nil if value.nil? 20 | 21 | column_class = ActiveRecord::ConnectionAdapters::Column 22 | 23 | case type 24 | when :string, :decimal 25 | value 26 | when :integer 27 | column_class.value_to_integer(value) 28 | when :float 29 | value.to_f 30 | when :datetime 31 | TimeHelper.string_to_time(value) 32 | when :date 33 | column_class.value_to_date(value) 34 | when :boolean 35 | column_class.value_to_boolean(value) 36 | else value 37 | # Nothing. 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/hstore_accessor/macro.rb: -------------------------------------------------------------------------------- 1 | module HstoreAccessor 2 | module Macro 3 | module ClassMethods 4 | def hstore_accessor(hstore_attribute, fields) 5 | @@hstore_keys_and_types ||= {} 6 | 7 | "hstore_metadata_for_#{hstore_attribute}".tap do |method_name| 8 | singleton_class.send(:define_method, method_name) do 9 | fields 10 | end 11 | 12 | delegate method_name, to: :class 13 | end 14 | 15 | field_methods = Module.new 16 | 17 | if ActiveRecord::VERSION::STRING.to_f >= 4.2 18 | singleton_class.send(:define_method, :type_for_attribute) do |attribute| 19 | data_type = @@hstore_keys_and_types[attribute] 20 | if data_type 21 | TypeHelpers::TYPES[data_type].new 22 | else 23 | super(attribute) 24 | end 25 | end 26 | 27 | singleton_class.send(:define_method, :column_for_attribute) do |attribute| 28 | data_type = @@hstore_keys_and_types[attribute.to_s] 29 | if data_type 30 | TypeHelpers.column_type_for(attribute.to_s, data_type) 31 | else 32 | super(attribute) 33 | end 34 | end 35 | else 36 | field_methods.send(:define_method, :column_for_attribute) do |attribute| 37 | data_type = @@hstore_keys_and_types[attribute.to_s] 38 | if data_type 39 | TypeHelpers.column_type_for(attribute.to_s, data_type) 40 | else 41 | super(attribute) 42 | end 43 | end 44 | end 45 | 46 | fields.each do |key, type| 47 | data_type = type 48 | store_key = key 49 | 50 | if type.is_a?(Hash) 51 | type = type.with_indifferent_access 52 | data_type = type[:data_type] 53 | store_key = type[:store_key] 54 | end 55 | 56 | data_type = data_type.to_sym 57 | 58 | raise Serialization::InvalidDataTypeError unless Serialization::VALID_TYPES.include?(data_type) 59 | 60 | @@hstore_keys_and_types[key.to_s] = data_type 61 | 62 | field_methods.instance_eval do 63 | define_method("#{key}=") do |value| 64 | casted_value = TypeHelpers.cast(data_type, value) 65 | serialized_value = Serialization.serialize(data_type, casted_value) 66 | 67 | unless send(key) == casted_value 68 | send("#{hstore_attribute}_will_change!") 69 | end 70 | 71 | send("#{hstore_attribute}=", (send(hstore_attribute) || {}).merge(store_key.to_s => serialized_value)) 72 | end 73 | 74 | define_method(key) do 75 | value = send(hstore_attribute) && send(hstore_attribute).with_indifferent_access[store_key.to_s] 76 | Serialization.deserialize(data_type, value) 77 | end 78 | 79 | define_method("#{key}?") do 80 | send(key).present? 81 | end 82 | 83 | define_method("#{key}_changed?") do 84 | send("#{key}_change").present? 85 | end 86 | 87 | define_method("#{key}_was") do 88 | (send(:attribute_was, hstore_attribute.to_s) || {})[key.to_s] 89 | end 90 | 91 | define_method("#{key}_change") do 92 | hstore_changes = send("#{hstore_attribute}_change") 93 | return if hstore_changes.nil? 94 | attribute_changes = hstore_changes.map { |change| change.try(:[], store_key.to_s) } 95 | attribute_changes.uniq.size == 1 ? nil : attribute_changes 96 | end 97 | 98 | define_method("restore_#{key}!") do 99 | old_hstore = send("#{hstore_attribute}_change").try(:first) || {} 100 | send("#{key}=", old_hstore[key.to_s]) 101 | end 102 | 103 | define_method("reset_#{key}!") do 104 | if ActiveRecord::VERSION::STRING.to_f >= 4.2 105 | ActiveSupport::Deprecation.warn(<<-MSG.squish) 106 | `#reset_#{key}!` is deprecated and will be removed on Rails 5. 107 | Please use `#restore_#{key}!` instead. 108 | MSG 109 | end 110 | send("restore_#{key}!") 111 | end 112 | 113 | define_method("#{key}_will_change!") do 114 | send("#{hstore_attribute}_will_change!") 115 | end 116 | end 117 | 118 | query_field = "#{table_name}.#{hstore_attribute} -> '#{store_key}'" 119 | eq_query_field = "#{table_name}.#{hstore_attribute} @> hstore('#{store_key}', ?)" 120 | 121 | case data_type 122 | when :string 123 | send(:scope, "with_#{key}", -> value { where(eq_query_field, value.to_s) }) 124 | when :integer 125 | send(:scope, "#{key}_lt", -> value { where("(#{query_field})::#{data_type} < ?", value.to_s) }) 126 | send(:scope, "#{key}_lte", -> value { where("(#{query_field})::#{data_type} <= ?", value.to_s) }) 127 | send(:scope, "#{key}_eq", -> value { where(eq_query_field, value.to_s) }) 128 | send(:scope, "#{key}_gte", -> value { where("(#{query_field})::#{data_type} >= ?", value.to_s) }) 129 | send(:scope, "#{key}_gt", -> value { where("(#{query_field})::#{data_type} > ?", value.to_s) }) 130 | when :float, :decimal 131 | send(:scope, "#{key}_lt", -> value { where("(#{query_field})::#{data_type} < ?", value.to_s) }) 132 | send(:scope, "#{key}_lte", -> value { where("(#{query_field})::#{data_type} <= ?", value.to_s) }) 133 | send(:scope, "#{key}_eq", -> value { where("(#{query_field})::#{data_type} = ?", value.to_s) }) 134 | send(:scope, "#{key}_gte", -> value { where("(#{query_field})::#{data_type} >= ?", value.to_s) }) 135 | send(:scope, "#{key}_gt", -> value { where("(#{query_field})::#{data_type} > ?", value.to_s) }) 136 | when :datetime 137 | send(:scope, "#{key}_before", -> value { where("(#{query_field})::integer < ?", value.to_i) }) 138 | send(:scope, "#{key}_eq", -> value { where(eq_query_field, value.to_i.to_s) }) 139 | send(:scope, "#{key}_after", -> value { where("(#{query_field})::integer > ?", value.to_i) }) 140 | when :date 141 | send(:scope, "#{key}_before", -> value { where("#{query_field} < ?", value.to_s) }) 142 | send(:scope, "#{key}_eq", -> value { where(eq_query_field, value.to_s) }) 143 | send(:scope, "#{key}_after", -> value { where("#{query_field} > ?", value.to_s) }) 144 | when :boolean 145 | send(:scope, "is_#{key}", -> { where(eq_query_field, "true") }) 146 | send(:scope, "not_#{key}", -> { where(eq_query_field, "false") }) 147 | end 148 | end 149 | 150 | include field_methods 151 | end 152 | end 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/hstore_accessor/serialization.rb: -------------------------------------------------------------------------------- 1 | module HstoreAccessor 2 | module Serialization 3 | InvalidDataTypeError = Class.new(StandardError) 4 | 5 | VALID_TYPES = [ 6 | :boolean, 7 | :date, 8 | :datetime, 9 | :decimal, 10 | :float, 11 | :integer, 12 | :string 13 | ] 14 | 15 | DEFAULT_SERIALIZER = ->(value) { value.to_s } 16 | DEFAULT_DESERIALIZER = DEFAULT_SERIALIZER 17 | 18 | SERIALIZERS = { 19 | boolean: -> value { (value.to_s == "true").to_s }, 20 | date: -> value { value && value.to_s }, 21 | datetime: -> value { value && value.to_i } 22 | } 23 | SERIALIZERS.default = DEFAULT_SERIALIZER 24 | 25 | DESERIALIZERS = { 26 | boolean: -> value { TypeHelpers.cast(:boolean, value) }, 27 | date: -> value { value && Date.parse(value) }, 28 | decimal: -> value { value && (value == '' ? BigDecimal.new(0) : BigDecimal.new(value)) }, 29 | float: -> value { value && value.to_f }, 30 | integer: -> value { value && value.to_i }, 31 | datetime: -> value { value && Time.at(value.to_i).in_time_zone } 32 | } 33 | DESERIALIZERS.default = DEFAULT_DESERIALIZER 34 | 35 | class << self 36 | def serialize(type, value, serializer=SERIALIZERS[type]) 37 | return nil if value.nil? 38 | 39 | serializer.call(value) 40 | end 41 | 42 | def deserialize(type, value, deserializer=DESERIALIZERS[type]) 43 | return nil if value.nil? 44 | 45 | deserializer.call(value) 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/hstore_accessor/version.rb: -------------------------------------------------------------------------------- 1 | module HstoreAccessor 2 | VERSION = "1.1.1" 3 | end 4 | -------------------------------------------------------------------------------- /spec/hstore_accessor_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "active_support/all" 3 | 4 | FIELDS = { 5 | name: :string, 6 | color: :string, 7 | price: :integer, 8 | published: { data_type: :boolean, store_key: "p" }, 9 | weight: { data_type: :float, store_key: "w" }, 10 | popular: :boolean, 11 | build_timestamp: :datetime, 12 | released_at: :date, 13 | likes: :integer, 14 | miles: :decimal 15 | } 16 | 17 | DATA_FIELDS = { 18 | color_data: :string 19 | } 20 | 21 | class ProductCategory < ActiveRecord::Base 22 | hstore_accessor :options, name: :string, likes: :integer 23 | has_many :products 24 | end 25 | 26 | class Product < ActiveRecord::Base 27 | hstore_accessor :options, FIELDS 28 | hstore_accessor :data, DATA_FIELDS 29 | belongs_to :product_category 30 | end 31 | 32 | class SuperProduct < Product 33 | end 34 | 35 | describe HstoreAccessor do 36 | context "macro" do 37 | let(:product) { Product.new } 38 | 39 | FIELDS.keys.each do |field| 40 | it "creates a getter for the hstore field: #{field}" do 41 | expect(product).to respond_to(field) 42 | end 43 | 44 | it "creates a setter for the hstore field: #{field}=" do 45 | expect(product).to respond_to(:"#{field}=") 46 | end 47 | end 48 | 49 | it "raises an InvalidDataTypeError if an invalid type is specified" do 50 | expect do 51 | class FakeModel 52 | include HstoreAccessor 53 | hstore_accessor :foo, bar: :baz 54 | end 55 | end.to raise_error(HstoreAccessor::InvalidDataTypeError) 56 | end 57 | 58 | it "stores using the store_key if one is provided" do 59 | product.weight = 38.5 60 | product.save 61 | product.reload 62 | expect(product.options["w"]).to eq "38.5" 63 | expect(product.weight).to eq 38.5 64 | end 65 | end 66 | 67 | context "#hstore_metadata_for_*" do 68 | let(:product) { Product } 69 | 70 | it "returns the metadata hash for the specified field" do 71 | expect(product.hstore_metadata_for_options).to eq FIELDS 72 | expect(product.hstore_metadata_for_data).to eq DATA_FIELDS 73 | end 74 | 75 | context "instance method" do 76 | subject { Product.new } 77 | it { is_expected.to delegate_method(:hstore_metadata_for_options).to(:class) } 78 | it { is_expected.to delegate_method(:hstore_metadata_for_data).to(:class) } 79 | end 80 | end 81 | 82 | context "nil values" do 83 | let!(:timestamp) { Time.now } 84 | let!(:datestamp) { Date.today } 85 | let(:product) { Product.new } 86 | let(:persisted_product) { Product.create!(color: "green", price: 10, weight: 10.1, popular: true, build_timestamp: (timestamp - 10.days), released_at: (datestamp - 8.days), miles: BigDecimal.new("9.133790001")) } 87 | 88 | FIELDS.keys.each do |field| 89 | it "responds with nil when #{field} is not set" do 90 | expect(product.send(field)).to be_nil 91 | end 92 | 93 | it "responds with nil when #{field} is set back to nil after being set initially" do 94 | persisted_product.send("#{field}=", nil) 95 | expect(persisted_product.send(field)).to be_nil 96 | end 97 | end 98 | end 99 | 100 | describe "predicate methods" do 101 | let!(:product) { Product.new } 102 | 103 | it "exist for each field" do 104 | FIELDS.keys.each do |field| 105 | expect(product).to respond_to "#{field}?" 106 | end 107 | end 108 | 109 | it "uses 'present?' to determine return value" do 110 | stub = double(present?: :result_of_present) 111 | expect(stub).to receive(:present?) 112 | allow(product).to receive_messages(color: stub) 113 | expect(product.color?).to eq(:result_of_present) 114 | end 115 | 116 | context "boolean fields" do 117 | it "return the state for true boolean fields" do 118 | product.popular = true 119 | product.save 120 | product.reload 121 | expect(product.popular?).to be true 122 | end 123 | 124 | it "return the state for false boolean fields" do 125 | product.popular = false 126 | product.save 127 | product.reload 128 | expect(product.popular?).to be false 129 | end 130 | 131 | it "return true for boolean field set via hash using real boolean" do 132 | product.options = { "popular" => true } 133 | expect(product.popular?).to be true 134 | end 135 | 136 | it "return false for boolean field set via hash using real boolean" do 137 | product.options = { "popular" => false } 138 | expect(product.popular?).to be false 139 | end 140 | 141 | it "return true for boolean field set via hash using string" do 142 | product.options = { "popular" => "true" } 143 | expect(product.popular?).to be true 144 | end 145 | 146 | it "return false for boolean field set via hash using string" do 147 | product.options = { "popular" => "false" } 148 | expect(product.popular?).to be false 149 | end 150 | end 151 | end 152 | 153 | describe "scopes" do 154 | let!(:timestamp) { Time.now } 155 | let!(:datestamp) { Date.today } 156 | let!(:product_a) { Product.create(likes: 3, name: "widget", color: "green", price: 10, weight: 10.1, popular: true, build_timestamp: (timestamp - 10.days), released_at: (datestamp - 8.days), miles: BigDecimal.new("10.113379001")) } 157 | let!(:product_b) { Product.create(color: "orange", price: 20, weight: 20.2, popular: false, build_timestamp: (timestamp - 5.days), released_at: (datestamp - 4.days), miles: BigDecimal.new("20.213379001")) } 158 | let!(:product_c) { Product.create(color: "blue", price: 30, weight: 30.3, popular: true, build_timestamp: timestamp, released_at: datestamp, miles: BigDecimal.new("30.313379001")) } 159 | 160 | context "ambiguous column names" do 161 | let!(:product_category) { ProductCategory.create!(name: "widget", likes: 2) } 162 | 163 | before do 164 | Product.all.to_a.each do |product| 165 | product_category.products << product 166 | end 167 | end 168 | 169 | context "eq query" do 170 | let!(:query) { Product.all.joins(:product_category).merge(ProductCategory.with_name("widget")).with_name("widget") } 171 | 172 | it "qualifies the table name to prevent ambiguous column name references" do 173 | expect { query.to_a }.to_not raise_error 174 | end 175 | end 176 | 177 | context "query" do 178 | let!(:query) { Product.all.joins(:product_category).merge(ProductCategory.likes_lt(4)).likes_lt(4) } 179 | 180 | it "qualifies the table name to prevent ambiguous column name references" do 181 | expect { query.to_a }.to_not raise_error 182 | end 183 | end 184 | end 185 | 186 | context "for string fields support" do 187 | it "equality" do 188 | expect(Product.with_color("orange").to_a).to eq [product_b] 189 | end 190 | end 191 | 192 | context "for integer fields support" do 193 | it "less than" do 194 | expect(Product.price_lt(20).to_a).to eq [product_a] 195 | end 196 | 197 | it "less than or equal" do 198 | expect(Product.price_lte(20).to_a).to eq [product_a, product_b] 199 | end 200 | 201 | it "equality" do 202 | expect(Product.price_eq(10).to_a).to eq [product_a] 203 | end 204 | 205 | it "greater than or equal" do 206 | expect(Product.price_gte(20).to_a).to eq [product_b, product_c] 207 | end 208 | 209 | it "greater than" do 210 | expect(Product.price_gt(20).to_a).to eq [product_c] 211 | end 212 | end 213 | 214 | context "for float fields support" do 215 | it "less than" do 216 | expect(Product.weight_lt(20.0).to_a).to eq [product_a] 217 | end 218 | 219 | it "less than or equal" do 220 | expect(Product.weight_lte(20.2).to_a).to eq [product_a, product_b] 221 | end 222 | 223 | it "equality" do 224 | expect(Product.weight_eq(10.1).to_a).to eq [product_a] 225 | end 226 | 227 | it "greater than or equal" do 228 | expect(Product.weight_gte(20.2).to_a).to eq [product_b, product_c] 229 | end 230 | 231 | it "greater than" do 232 | expect(Product.weight_gt(20.5).to_a).to eq [product_c] 233 | end 234 | end 235 | 236 | context "for decimal fields support" do 237 | it "less than" do 238 | expect(Product.miles_lt(BigDecimal.new("10.55555")).to_a).to eq [product_a] 239 | end 240 | 241 | it "less than or equal" do 242 | expect(Product.miles_lte(BigDecimal.new("20.213379001")).to_a).to eq [product_a, product_b] 243 | end 244 | 245 | it "equality" do 246 | expect(Product.miles_eq(BigDecimal.new("10.113379001")).to_a).to eq [product_a] 247 | end 248 | 249 | it "greater than or equal" do 250 | expect(Product.miles_gte(BigDecimal.new("20.213379001")).to_a).to eq [product_b, product_c] 251 | end 252 | 253 | it "greater than" do 254 | expect(Product.miles_gt(BigDecimal.new("20.555555")).to_a).to eq [product_c] 255 | end 256 | end 257 | 258 | context "for datetime fields support" do 259 | it "before" do 260 | expect(Product.build_timestamp_before(timestamp)).to eq [product_a, product_b] 261 | end 262 | 263 | it "equality" do 264 | expect(Product.build_timestamp_eq(timestamp)).to eq [product_c] 265 | end 266 | 267 | it "after" do 268 | expect(Product.build_timestamp_after(timestamp - 6.days)).to eq [product_b, product_c] 269 | end 270 | end 271 | 272 | context "for date fields support" do 273 | it "before" do 274 | expect(Product.released_at_before(datestamp)).to eq [product_a, product_b] 275 | end 276 | 277 | it "equality" do 278 | expect(Product.released_at_eq(datestamp)).to eq [product_c] 279 | end 280 | 281 | it "after" do 282 | expect(Product.released_at_after(datestamp - 6.days)).to eq [product_b, product_c] 283 | end 284 | end 285 | 286 | context "for boolean field support" do 287 | it "true" do 288 | expect(Product.is_popular).to eq [product_a, product_c] 289 | end 290 | 291 | it "false" do 292 | expect(Product.not_popular).to eq [product_b] 293 | end 294 | end 295 | end 296 | 297 | describe "#type_for_attribute" do 298 | if ::ActiveRecord::VERSION::STRING.to_f >= 4.2 299 | subject { SuperProduct } 300 | 301 | def self.it_returns_the_type_for_the_attribute(type, attribute_name, active_record_type) 302 | context "#{type}" do 303 | it "returns the type for the column" do 304 | expect(subject.type_for_attribute(attribute_name.to_s)).to eq(active_record_type.new) 305 | end 306 | end 307 | end 308 | 309 | it_returns_the_type_for_the_attribute "default behavior", :string_type, ActiveRecord::Type::String 310 | it_returns_the_type_for_the_attribute :string, :color, ActiveRecord::Type::String 311 | it_returns_the_type_for_the_attribute :integer, :price, ActiveRecord::Type::Integer 312 | it_returns_the_type_for_the_attribute :float, :weight, ActiveRecord::Type::Float 313 | it_returns_the_type_for_the_attribute :datetime, :build_timestamp, ActiveRecord::Type::DateTime 314 | it_returns_the_type_for_the_attribute :date, :released_at, ActiveRecord::Type::Date 315 | it_returns_the_type_for_the_attribute :boolean, :published, ActiveRecord::Type::Boolean 316 | else 317 | subject { Product } 318 | 319 | it "is not defined" do 320 | expect(subject).to_not respond_to(:type_for_attribute) 321 | end 322 | end 323 | end 324 | 325 | describe "#column_for_attribute" do 326 | if ActiveRecord::VERSION::STRING.to_f >= 5.0 327 | def self.it_returns_the_properly_typed_column(type, attribute_name, cast_type_class) 328 | context "#{type}" do 329 | subject { SuperProduct.column_for_attribute(attribute_name) } 330 | it "returns a column with a #{type} cast type" do 331 | expect(subject).to be_a(ActiveRecord::ConnectionAdapters::Column) 332 | expect(subject.sql_type_metadata).to eq(cast_type_class.new) 333 | end 334 | end 335 | end 336 | 337 | it_returns_the_properly_typed_column :string, :color, ActiveRecord::Type::String 338 | it_returns_the_properly_typed_column :integer, :price, ActiveRecord::Type::Integer 339 | it_returns_the_properly_typed_column :boolean, :published, ActiveRecord::Type::Boolean 340 | it_returns_the_properly_typed_column :float, :weight, ActiveRecord::Type::Float 341 | it_returns_the_properly_typed_column :datetime, :build_timestamp, ActiveRecord::Type::DateTime 342 | it_returns_the_properly_typed_column :date, :released_at, ActiveRecord::Type::Date 343 | it_returns_the_properly_typed_column :decimal, :miles, ActiveRecord::Type::Decimal 344 | 345 | elsif ActiveRecord::VERSION::STRING.to_f >= 4.2 346 | 347 | def self.it_returns_the_properly_typed_column(type, attribute_name, cast_type_class) 348 | context "#{type}" do 349 | subject { SuperProduct.column_for_attribute(attribute_name) } 350 | it "returns a column with a #{type} cast type" do 351 | expect(subject).to be_a(ActiveRecord::ConnectionAdapters::Column) 352 | expect(subject.cast_type).to eq(cast_type_class.new) 353 | end 354 | end 355 | end 356 | 357 | it_returns_the_properly_typed_column :string, :color, ActiveRecord::Type::String 358 | it_returns_the_properly_typed_column :integer, :price, ActiveRecord::Type::Integer 359 | it_returns_the_properly_typed_column :boolean, :published, ActiveRecord::Type::Boolean 360 | it_returns_the_properly_typed_column :float, :weight, ActiveRecord::Type::Float 361 | it_returns_the_properly_typed_column :datetime, :build_timestamp, ActiveRecord::Type::DateTime 362 | it_returns_the_properly_typed_column :date, :released_at, ActiveRecord::Type::Date 363 | it_returns_the_properly_typed_column :decimal, :miles, ActiveRecord::Type::Decimal 364 | else 365 | def self.it_returns_the_properly_typed_column(hstore_type, attribute_name, active_record_type) 366 | context "#{hstore_type}" do 367 | subject { SuperProduct.new.column_for_attribute(attribute_name) } 368 | it "returns a column with a #{hstore_type} cast type" do 369 | expect(subject).to be_a(ActiveRecord::ConnectionAdapters::Column) 370 | expect(subject.type).to eq(active_record_type) 371 | end 372 | end 373 | end 374 | 375 | it_returns_the_properly_typed_column :string, :color, :string 376 | it_returns_the_properly_typed_column :integer, :price, :integer 377 | it_returns_the_properly_typed_column :boolean, :published, :boolean 378 | it_returns_the_properly_typed_column :float, :weight, :float 379 | it_returns_the_properly_typed_column :time, :build_timestamp, :datetime 380 | it_returns_the_properly_typed_column :date, :released_at, :date 381 | it_returns_the_properly_typed_column :decimal, :miles, :decimal 382 | end 383 | end 384 | 385 | context "when assigning values it" do 386 | let(:product) { Product.new } 387 | 388 | it "correctly stores string values" do 389 | product.color = "blue" 390 | product.save 391 | product.reload 392 | expect(product.color).to eq "blue" 393 | end 394 | 395 | it "allows access to bulk set values via string before saving" do 396 | product.options = { 397 | "color" => "blue", 398 | "price" => 120 399 | } 400 | expect(product.color).to eq "blue" 401 | expect(product.price).to eq 120 402 | end 403 | 404 | it "allows access to bulk set values via :symbols before saving" do 405 | product.options = { 406 | color: "blue", 407 | price: 120 408 | } 409 | expect(product.color).to eq "blue" 410 | expect(product.price).to eq 120 411 | end 412 | 413 | it "correctly stores integer values" do 414 | product.price = 468 415 | product.save 416 | product.reload 417 | expect(product.price).to eq 468 418 | end 419 | 420 | it "correctly stores float values" do 421 | product.weight = 93.45 422 | product.save 423 | product.reload 424 | expect(product.weight).to eq 93.45 425 | end 426 | 427 | context "multipart values" do 428 | it "stores multipart dates correctly" do 429 | product.update_attributes!( 430 | "released_at(1i)" => "2014", 431 | "released_at(2i)" => "04", 432 | "released_at(3i)" => "14" 433 | ) 434 | product.reload 435 | expect(product.released_at).to eq(Date.new(2014, 4, 14)) 436 | end 437 | end 438 | 439 | context "time values" do 440 | let(:chicago_timezone) { ActiveSupport::TimeZone["America/Chicago"] } 441 | let(:new_york_timezone) { ActiveSupport::TimeZone["America/New_York"] } 442 | 443 | it "correctly stores value" do 444 | timestamp = Time.now - 10.days 445 | product.build_timestamp = timestamp 446 | product.save! 447 | product.reload 448 | expect(product.build_timestamp.to_i).to eq timestamp.to_i 449 | end 450 | 451 | it "stores the value in utc" do 452 | timestamp = Time.now.in_time_zone(chicago_timezone) - 10.days 453 | product.build_timestamp = timestamp 454 | product.save! 455 | product.reload 456 | expect(product.options["build_timestamp"].to_i).to eq timestamp.utc.to_i 457 | end 458 | 459 | it "returns the time value in the current time zone" do 460 | timestamp = Time.now.in_time_zone(chicago_timezone) - 10.days 461 | product.build_timestamp = timestamp 462 | product.save! 463 | product.reload 464 | Time.use_zone(new_york_timezone) do 465 | expect(product.build_timestamp.to_s).to eq timestamp.in_time_zone(new_york_timezone).to_s 466 | end 467 | end 468 | end 469 | 470 | it "correctly stores date values" do 471 | datestamp = Date.today - 9.days 472 | product.released_at = datestamp 473 | product.save 474 | product.reload 475 | expect(product.released_at.to_s).to eq datestamp.to_s 476 | expect(product.released_at).to eq datestamp 477 | end 478 | 479 | it "correctly stores decimal values" do 480 | decimal = BigDecimal.new("9.13370009001") 481 | product.miles = decimal 482 | product.save 483 | product.reload 484 | expect(product.miles.to_s).to eq decimal.to_s 485 | expect(product.miles).to eq decimal 486 | end 487 | 488 | context "correctly stores boolean values" do 489 | it "when string 'true' is passed" do 490 | product.popular = "true" 491 | product.save 492 | product.reload 493 | expect(product.popular).to be true 494 | end 495 | 496 | it "when a real boolean is passed" do 497 | product.popular = true 498 | product.save 499 | product.reload 500 | expect(product.popular).to be true 501 | end 502 | end 503 | 504 | it "setters call the _will_change! method of the store attribute" do 505 | expect(product).to receive(:options_will_change!) 506 | product.color = "green" 507 | end 508 | 509 | describe "type casting" do 510 | it "type casts integer values" do 511 | product.price = "468" 512 | expect(product.price).to eq 468 513 | end 514 | 515 | it "type casts float values" do 516 | product.weight = "93.45" 517 | expect(product.weight).to eq 93.45 518 | end 519 | 520 | it "type casts time values" do 521 | timestamp = Time.now - 10.days 522 | product.build_timestamp = timestamp.to_s 523 | expect(product.build_timestamp.to_i).to eq timestamp.to_i 524 | end 525 | 526 | it "type casts date values" do 527 | datestamp = Date.today - 9.days 528 | product.released_at = datestamp.to_s 529 | expect(product.released_at).to eq datestamp 530 | end 531 | 532 | it "type casts decimal values" do 533 | product.miles = "1.337900129339202" 534 | expect(product.miles).to eq BigDecimal.new("1.337900129339202") 535 | end 536 | 537 | it "type casts boolean values" do 538 | [true, 1, "t"].each do |value| 539 | product.popular = value 540 | expect(product.popular).to be true 541 | 542 | product.published = value 543 | expect(product.published).to be true 544 | end 545 | 546 | [false, 0, "f"].each do |value| 547 | product.popular = value 548 | expect(product.popular).to be false 549 | 550 | product.published = value 551 | expect(product.published).to be false 552 | end 553 | end 554 | end 555 | 556 | context "extended getters and setters" do 557 | before do 558 | class Product 559 | alias_method :set_color, :color= 560 | alias_method :get_color, :color 561 | 562 | def color=(value) 563 | super(value.upcase) 564 | end 565 | 566 | def color 567 | super.try(:downcase) 568 | end 569 | end 570 | end 571 | 572 | after do 573 | class Product 574 | alias_method :color=, :set_color 575 | alias_method :color, :get_color 576 | end 577 | end 578 | 579 | context "setters" do 580 | it "can be wrapped" do 581 | product.color = "red" 582 | expect(product.options["color"]).to eq("RED") 583 | end 584 | end 585 | 586 | context "getters" do 587 | it "can be wrapped" do 588 | product.color = "GREEN" 589 | expect(product.color).to eq("green") 590 | end 591 | end 592 | end 593 | end 594 | 595 | describe "dirty tracking" do 596 | let(:product) { Product.new } 597 | 598 | it "_changed? should return the expected value" do 599 | expect(product.color_changed?).to be false 600 | product.color = "ORANGE" 601 | expect(product.price_changed?).to be false 602 | expect(product.color_changed?).to be true 603 | product.save 604 | expect(product.color_changed?).to be false 605 | product.color = "ORANGE" 606 | expect(product.color_changed?).to be false 607 | 608 | expect(product.price_changed?).to be false 609 | product.price = 100 610 | expect(product.price_changed?).to be true 611 | product.save 612 | expect(product.price_changed?).to be false 613 | product.price = "100" 614 | expect(product.price).to be 100 615 | expect(product.price_changed?).to be false 616 | end 617 | 618 | describe "#_will_change!" do 619 | it "tells ActiveRecord the hstore attribute has changed" do 620 | expect(product).to receive(:options_will_change!) 621 | product.color_will_change! 622 | end 623 | end 624 | 625 | describe "#_was" do 626 | it "returns the expected value" do 627 | product.color = "ORANGE" 628 | product.save 629 | product.color = "GREEN" 630 | expect(product.color_was).to eq "ORANGE" 631 | end 632 | 633 | it "works when the hstore attribute is nil" do 634 | product.options = nil 635 | product.save 636 | product.color = "green" 637 | expect { product.color_was }.to_not raise_error 638 | end 639 | end 640 | 641 | describe "#_change" do 642 | it "returns the old and new values" do 643 | product.color = "ORANGE" 644 | product.save 645 | product.color = "GREEN" 646 | expect(product.color_change).to eq %w(ORANGE GREEN) 647 | end 648 | 649 | context "when store_key differs from key" do 650 | it "returns the old and new values" do 651 | product.weight = 100.01 652 | expect(product.weight_change[1]).to eq "100.01" 653 | end 654 | end 655 | 656 | context "hstore attribute was nil" do 657 | it "returns old and new values" do 658 | product.options = nil 659 | product.save! 660 | green = product.color = "green" 661 | expect(product.color_change).to eq([nil, green]) 662 | end 663 | end 664 | 665 | context "other hstore attributes were persisted" do 666 | it "returns nil" do 667 | product.price = 5 668 | product.save! 669 | product.price = 6 670 | expect(product.color_change).to be_nil 671 | end 672 | 673 | it "returns nil when other attributes were changed" do 674 | product.price = 5 675 | product.save! 676 | product = Product.first 677 | 678 | expect(product.price_change).to be_nil 679 | 680 | product.color = "red" 681 | 682 | expect(product.price_change).to be_nil 683 | end 684 | end 685 | 686 | context "not persisted" do 687 | it "returns nil when there are no changes" do 688 | expect(product.color_change).to be_nil 689 | end 690 | end 691 | end 692 | 693 | describe "#reset_!" do 694 | before do 695 | allow(ActiveSupport::Deprecation).to receive(:warn) 696 | end 697 | 698 | if ActiveRecord::VERSION::STRING.to_f >= 4.2 699 | it "displays a deprecation warning" do 700 | expect(ActiveSupport::Deprecation).to receive(:warn) 701 | product.reset_color! 702 | end 703 | else 704 | it "does not display a deprecation warning" do 705 | expect(ActiveSupport::Deprecation).to_not receive(:warn) 706 | product.reset_color! 707 | end 708 | end 709 | 710 | it "restores the attribute" do 711 | expect(product).to receive(:restore_color!) 712 | product.reset_color! 713 | end 714 | end 715 | 716 | describe "#restore_!" do 717 | it "restores the attribute" do 718 | product.color = "red" 719 | product.restore_color! 720 | expect(product.color).to be_nil 721 | end 722 | 723 | context "persisted" do 724 | it "restores the attribute" do 725 | green = product.color = "green" 726 | product.save! 727 | product.color = "red" 728 | product.restore_color! 729 | expect(product.color).to eq(green) 730 | end 731 | end 732 | end 733 | end 734 | end 735 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "hstore_accessor" 2 | require "database_cleaner" 3 | require "shoulda-matchers" 4 | 5 | DatabaseCleaner.strategy = :truncation 6 | 7 | Shoulda::Matchers.configure do |config| 8 | config.integrate do |with| 9 | with.test_framework :rspec 10 | with.library :active_record 11 | end 12 | end 13 | 14 | RSpec.configure do |config| 15 | config.mock_with :rspec 16 | config.order = :random 17 | 18 | config.before :suite do 19 | create_database 20 | end 21 | 22 | config.before do 23 | DatabaseCleaner.clean 24 | end 25 | end 26 | 27 | def create_database 28 | ActiveRecord::Base.establish_connection( 29 | adapter: "postgresql", 30 | database: "hstore_accessor", 31 | username: "postgres" 32 | ) 33 | 34 | ActiveRecord::Base.connection.execute("CREATE EXTENSION hstore;") rescue ActiveRecord::StatementInvalid 35 | ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS products;") 36 | ActiveRecord::Base.connection.execute("DROP TABLE IF EXISTS product_categories;") 37 | 38 | ActiveRecord::Base.connection.create_table(:products) do |t| 39 | t.hstore :options 40 | t.hstore :data 41 | 42 | t.string :string_type 43 | t.integer :integer_type 44 | t.integer :product_category_id 45 | t.boolean :boolean_type 46 | t.float :float_type 47 | t.time :time_type 48 | t.date :date_type 49 | t.datetime :datetime_type 50 | t.decimal :decimal_type 51 | end 52 | 53 | ActiveRecord::Base.connection.create_table(:product_categories) do |t| 54 | t.hstore :options 55 | end 56 | end 57 | --------------------------------------------------------------------------------