├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .rspec ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── activerecord-typedstore.gemspec ├── gemfiles ├── Gemfile.ar-6.1 ├── Gemfile.ar-7.0 ├── Gemfile.ar-7.1 └── Gemfile.ar-edge ├── lib ├── active_record │ ├── typed_store.rb │ └── typed_store │ │ ├── behavior.rb │ │ ├── dsl.rb │ │ ├── extension.rb │ │ ├── field.rb │ │ ├── identity_coder.rb │ │ ├── type.rb │ │ ├── typed_hash.rb │ │ └── version.rb └── activerecord-typedstore.rb └── spec ├── active_record ├── typed_store │ └── typed_hash_spec.rb └── typed_store_spec.rb ├── spec_helper.rb └── support ├── database_cleaner.rb └── models.rb /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | name: Ruby 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | name: ${{ matrix.ruby }} / Rails ${{ matrix.rails }} / TZ ${{ matrix.timezone_aware }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | ruby: ['2.7', '3.0', '3.1', '3.2', '3.3'] 13 | rails: ['6.1', '7.0', '7.1', 'edge'] 14 | timezone_aware: [0, 1] 15 | exclude: 16 | - ruby: '3.2' 17 | rails: '6.1' 18 | - ruby: '3.3' 19 | rails: '6.1' 20 | - ruby: '2.7' 21 | rails: 'edge' 22 | - ruby: '3.0' 23 | rails: 'edge' 24 | env: 25 | BUNDLE_GEMFILE: gemfiles/Gemfile.ar-${{ matrix.rails }} 26 | TIMEZONE_AWARE: ${{ matrix.timezone_aware }} 27 | POSTGRES: 1 28 | MYSQL: 1 29 | POSTGRES_JSON: 1 30 | steps: 31 | - name: Check out code 32 | uses: actions/checkout@v3 33 | - name: Set up Ruby ${{ matrix.ruby }} 34 | uses: ruby/setup-ruby@v1 35 | with: 36 | ruby-version: ${{ matrix.ruby }} 37 | bundler-cache: true 38 | - name: Ruby Tests 39 | run: bundle exec rake 40 | -------------------------------------------------------------------------------- /.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 | gemfiles/*.lock 19 | 20 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in active_record_typed_store.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Jean Boussier 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 | # ActiveRecord::TypedStore 2 | 3 | [![Gem Version](https://badge.fury.io/rb/activerecord-typedstore.svg)](http://badge.fury.io/rb/activerecord-typedstore) 4 | 5 | [ActiveRecord::Store](http://api.rubyonrails.org/classes/ActiveRecord/Store.html) but with typed attributes. 6 | 7 | 8 | ## Installation 9 | 10 | Add this line to your application's Gemfile: 11 | 12 | gem 'activerecord-typedstore' 13 | 14 | And then execute: 15 | 16 | $ bundle 17 | 18 | Or install it yourself as: 19 | 20 | $ gem install activerecord-typedstore 21 | 22 | ## Usage 23 | 24 | It works exactly like [ActiveRecord::Store documentation](http://api.rubyonrails.org/classes/ActiveRecord/Store.html) but you can declare the type of your attributes. 25 | 26 | Attributes definition is similar to activerecord's migrations: 27 | 28 | ```ruby 29 | 30 | class Shop < ActiveRecord::Base 31 | 32 | typed_store :settings do |s| 33 | s.boolean :public, default: false, null: false 34 | s.string :email 35 | s.datetime :publish_at 36 | s.integer :age, null: false 37 | 38 | # You can define array attributes like in rails 4 and postgres 39 | s.string :tags, array: true, default: [], null: false 40 | 41 | # In addition to prevent null values you can prevent blank values 42 | s.string :title, blank: false, default: 'Title' 43 | 44 | # If you don't want to enforce a datatype but still like to have default handling 45 | s.any :source, blank: false, default: 'web' 46 | end 47 | 48 | # You can use any ActiveModel validator 49 | validates :age, presence: true 50 | 51 | end 52 | 53 | # Values are accessible like normal model attributes 54 | shop = Shop.new(email: 'george@cyclim.se') 55 | shop.public? # => false 56 | shop.email # => 'george@cyclim.se' 57 | shop.published_at # => nil 58 | 59 | # Values are type casted 60 | shop.update_attributes( 61 | age: '42', 62 | published_at: '1984-06-08 13:57:12' 63 | ) 64 | shop.age # => 42 65 | shop.published_at.class #= DateTime 66 | 67 | # And changes are tracked 68 | shop.age_changed? # => false 69 | shop.age = 12 70 | shop.age_changed? # => true 71 | shop.age_was # => 42 72 | 73 | # You can still use it as a regular store 74 | shop.settings[:unknown] = 'Hello World' 75 | shop.save 76 | shop.reload 77 | shop.settings[:unknown] # => 'Hello World' 78 | 79 | # You can group attributes with a prefix or suffix 80 | typed_store(:browser, prefix: true) { |s| s.string :ip } # => #browser_ip 81 | typed_store(:browser, prefix: :web) { |s| s.string :ip } # => #web_ip 82 | typed_store(:browser, suffix: true) { |s| s.string :ip } # => #ip_browser 83 | typed_store(:browser, suffix: :web) { |s| s.string :ip } # => #ip_web 84 | 85 | # If you only want type casting and default handling without accessors 86 | 87 | # you can disable them store wide 88 | typed_store :settings, accessors: false do |s| 89 | # ... 90 | end 91 | 92 | # or on a per attribute basis 93 | typed_store :settings do |s| 94 | s.integer :age 95 | s.string :postal_code, accessor: false 96 | end 97 | 98 | ``` 99 | 100 | Type casting rules and attribute behavior are exactly the same as for real database columns. 101 | Actually the only difference is that you won't be able to query on these attributes (unless you use JSON or Postgres HStore types) and that you don't need to do a migration to add / remove an attribute. 102 | 103 | If not, then please fill in an issue. 104 | 105 | ## Serialization methods 106 | 107 | Just like for store, you can use any custom coder: 108 | 109 | ```ruby 110 | module Base64MarshalCoder 111 | extend self 112 | 113 | def load(data) 114 | return {} unless data 115 | Marshal.load(Base64.decode64(data)) 116 | end 117 | 118 | def dump(data) 119 | Base64.encode64(Marshal.dump(data || {})) 120 | end 121 | 122 | end 123 | 124 | typed_store :settings, coder: Base64MarshalCoder do |s| 125 | # ... 126 | end 127 | ``` 128 | 129 | If you want to use JSON column or Postgres HStore types, then you can pass in `ActiveRecord::TypedStore::IdentityCoder` as the coder. 130 | 131 | ## HStore limitations 132 | 133 | If you want to persist your store in a Postgres HStore, then there is some limitations imposed by the current HStore implementation in Postgres. 134 | Since HStore can only store strings: 135 | 136 | - `array` attributes won't work 137 | - `any` attributes will be converted to string 138 | 139 | If you use HStore because you need to be able to query the store from SQL, and any of these limitations are an issue for you, 140 | then you could probably use the JSON column type, which do not suffer from these limitations and is also queriable. 141 | 142 | ## Contributing 143 | 144 | 1. Fork it 145 | 2. Create your feature branch (`git checkout -b my-new-feature`) 146 | 3. Commit your changes (`git commit -am 'Add some feature'`) 147 | 4. Push to the branch (`git push origin my-new-feature`) 148 | 5. Create new Pull Request 149 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | 8 | namespace :gemfiles do 9 | task :update do 10 | Dir[File.expand_path('gemfiles/*')].each do |file| 11 | next if file.end_with?('.lock') 12 | command = %W{ 13 | BUNDLE_GEMFILE='#{file}' 14 | bundle update 15 | }.join(' ') 16 | puts command 17 | system(command) 18 | end 19 | end 20 | end 21 | 22 | namespace :spec do 23 | task :all do 24 | %w(4.2 5.0 5.1).each do |ar_version| 25 | [1, 0].each do |timezone_aware| 26 | command = %W{ 27 | BUNDLE_GEMFILE=gemfiles/Gemfile.ar-#{ar_version} 28 | TIMEZONE_AWARE=#{timezone_aware} 29 | MYSQL=1 30 | POSTGRES=1 31 | rspec 32 | }.join(' ') 33 | puts command 34 | system(command) 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /activerecord-typedstore.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'active_record/typed_store/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'activerecord-typedstore' 8 | spec.version = ActiveRecord::TypedStore::VERSION 9 | spec.authors = ['Jean Boussier'] 10 | spec.email = ['jean.boussier@gmail.com'] 11 | spec.description = %q{ActiveRecord::Store but with type definition} 12 | spec.summary = %q{Add type casting and full method attributes support to АctiveRecord store} 13 | spec.homepage = 'https://github.com/byroot/activerecord-typedstore' 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', '>= 6.1' 22 | 23 | spec.add_development_dependency 'bundler' 24 | spec.add_development_dependency 'rake' 25 | spec.add_development_dependency 'rspec', '~> 3' 26 | spec.add_development_dependency 'sqlite3', '~> 1' 27 | spec.add_development_dependency 'database_cleaner', '~> 1' 28 | end 29 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.ar-6.1: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '..' 4 | 5 | gem 'activerecord', '~> 6.1.0' 6 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.ar-7.0: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '..' 4 | 5 | gem 'activerecord', '~> 7.0.0' 6 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.ar-7.1: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '..' 4 | 5 | gem 'activerecord', '~> 7.0.0' 6 | -------------------------------------------------------------------------------- /gemfiles/Gemfile.ar-edge: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec path: '..' 4 | 5 | gem 'activerecord', github: 'rails/rails' 6 | -------------------------------------------------------------------------------- /lib/active_record/typed_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support' 4 | 5 | module ActiveRecord 6 | module TypedStore 7 | end 8 | end 9 | 10 | ActiveSupport.on_load(:active_record) do 11 | require 'active_record/typed_store/extension' 12 | ::ActiveRecord::Base.extend(ActiveRecord::TypedStore::Extension) 13 | end 14 | -------------------------------------------------------------------------------- /lib/active_record/typed_store/behavior.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord::TypedStore 4 | module Behavior 5 | extend ActiveSupport::Concern 6 | 7 | module ClassMethods 8 | def define_attribute_methods 9 | super 10 | define_typed_store_attribute_methods 11 | end 12 | 13 | def undefine_attribute_methods # :nodoc: 14 | super if @typed_store_attribute_methods_generated 15 | @typed_store_attribute_methods_generated = false 16 | end 17 | 18 | def define_typed_store_attribute_methods 19 | return if @typed_store_attribute_methods_generated 20 | store_accessors.each do |attribute| 21 | define_attribute_method(attribute) 22 | undefine_before_type_cast_method(attribute) 23 | end 24 | @typed_store_attribute_methods_generated = true 25 | end 26 | 27 | def undefine_before_type_cast_method(attribute) 28 | # because it mess with ActionView forms, see #14. 29 | method = "#{attribute}_before_type_cast" 30 | undef_method(method) if method_defined?(method) 31 | end 32 | end 33 | 34 | def changes 35 | changes = super 36 | self.class.store_accessors.each do |attr| 37 | if send("#{attr}_changed?") 38 | changes[attr] = [send("#{attr}_was"), send(attr)] 39 | end 40 | end 41 | changes 42 | end 43 | 44 | def clear_attribute_change(attr_name) 45 | return if self.class.store_accessors.include?(attr_name.to_s) 46 | super 47 | end 48 | 49 | def read_attribute(attr_name) 50 | if self.class.store_accessors.include?(attr_name.to_s) 51 | return public_send(attr_name) 52 | end 53 | super 54 | end 55 | 56 | def attribute?(attr_name) 57 | if self.class.store_accessors.include?(attr_name.to_s) 58 | value = public_send(attr_name) 59 | 60 | case value 61 | when true then true 62 | when false, nil then false 63 | else 64 | if value.respond_to?(:zero?) 65 | !value.zero? 66 | else 67 | !value.blank? 68 | end 69 | end 70 | else 71 | super 72 | end 73 | end 74 | 75 | private 76 | 77 | if ActiveRecord.version.segments.first >= 7 78 | def attribute_names_for_partial_inserts 79 | # Contrary to all vanilla Rails types, typedstore attribute have an inherent default 80 | # value that doesn't match the database column default. 81 | # As such we need to insert them on partial inserts even if they weren't changed. 82 | super | self.class.typed_stores.keys.map(&:to_s) 83 | end 84 | 85 | def attribute_names_for_partial_updates 86 | # On partial updates we shouldn't need to force stores to be persisted. However since 87 | # we weren't persisting them for a while on insertion, we now need to gracefully deal 88 | # with existing records that may have been persisted with a `NULL` store 89 | # We use `blank?` as an heuristic to detect these. 90 | super | self.class.typed_stores.keys.map(&:to_s).select do |store| 91 | has_attribute?(store) && read_attribute_before_type_cast(store).blank? 92 | end 93 | end 94 | else 95 | # Rails 6.1 capability 96 | def attribute_names_for_partial_writes 97 | super | self.class.typed_stores.keys.map(&:to_s).select do |store| 98 | has_attribute?(store) && read_attribute_before_type_cast(store).blank? 99 | end 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/active_record/typed_store/dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record/typed_store/field' 4 | 5 | module ActiveRecord::TypedStore 6 | class DSL 7 | attr_reader :fields, :coder 8 | 9 | def initialize(store_name, options) 10 | @coder = options.fetch(:coder) { default_coder(store_name) } 11 | @store_name = store_name 12 | @prefix = 13 | case options[:prefix] 14 | when String, Symbol 15 | "#{options[:prefix]}_" 16 | when true 17 | "#{store_name}_" 18 | when false, nil 19 | "" 20 | else 21 | raise ArgumentError, "Unexpected type for prefix option. Expected string, symbol, or boolean" 22 | end 23 | @suffix = 24 | case options[:suffix] 25 | when String, Symbol 26 | "_#{options[:suffix]}" 27 | when true 28 | "_#{store_name}" 29 | when false, nil 30 | "" 31 | else 32 | raise ArgumentError, "Unexpected type for suffix option. Expected string, symbol, or boolean" 33 | end 34 | @accessors = if options[:accessors] == false 35 | {} 36 | elsif options[:accessors].is_a?(Array) 37 | options[:accessors].each_with_object({}) do |accessor_name, hash| 38 | hash[accessor_name] = accessor_key_for(accessor_name) 39 | end 40 | end 41 | @fields = {} 42 | yield self 43 | end 44 | 45 | if ActiveRecord.gem_version < Gem::Version.new('5.1.0') 46 | def default_coder(attribute_name) 47 | ActiveRecord::Coders::YAMLColumn.new 48 | end 49 | else 50 | def default_coder(attribute_name) 51 | ActiveRecord::Coders::YAMLColumn.new(attribute_name) 52 | end 53 | end 54 | 55 | def accessors 56 | @accessors || @fields.values.select(&:accessor).each_with_object({}) do |field, hash| 57 | hash[field.name] = accessor_key_for(field.name) 58 | end 59 | end 60 | 61 | delegate :keys, to: :@fields 62 | 63 | NO_DEFAULT_GIVEN = Object.new 64 | [:string, :text, :integer, :float, :time, :datetime, :date, :boolean, :decimal, :any].each do |type| 65 | define_method(type) do |name, **options| 66 | @fields[name] = Field.new(name, type, options) 67 | end 68 | end 69 | alias_method :date_time, :datetime 70 | 71 | private 72 | 73 | def accessor_key_for(name) 74 | "#{@prefix}#{name}#{@suffix}" 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/active_record/typed_store/extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record/typed_store/dsl' 4 | require 'active_record/typed_store/behavior' 5 | require 'active_record/typed_store/type' 6 | require 'active_record/typed_store/typed_hash' 7 | require 'active_record/typed_store/identity_coder' 8 | 9 | module ActiveRecord::TypedStore 10 | module Extension 11 | def typed_store(store_attribute, options={}, &block) 12 | unless self < Behavior 13 | include Behavior 14 | class_attribute :typed_stores, :store_accessors, instance_accessor: false 15 | end 16 | 17 | store_options = options.slice(:prefix, :suffix) 18 | dsl = DSL.new(store_attribute, options, &block) 19 | self.typed_stores = (self.typed_stores || {}).merge(store_attribute => dsl) 20 | self.store_accessors = typed_stores.each_value.flat_map { |d| d.accessors.values }.map { |a| -a.to_s }.to_set 21 | 22 | typed_klass = TypedHash.create(dsl.fields.values) 23 | const_set("#{store_attribute}_hash".camelize, typed_klass) 24 | 25 | if ActiveRecord.version >= Gem::Version.new('7.2.0.alpha') 26 | decorate_attributes([store_attribute]) do |name, subtype| 27 | subtype = subtype.subtype if subtype.is_a?(Type) 28 | Type.new(typed_klass, dsl.coder, subtype) 29 | end 30 | elsif ActiveRecord.version >= Gem::Version.new('6.1.0.alpha') 31 | attribute(store_attribute) do |subtype| 32 | subtype = subtype.subtype if subtype.is_a?(Type) 33 | Type.new(typed_klass, dsl.coder, subtype) 34 | end 35 | else 36 | decorate_attribute_type(store_attribute, :typed_store) do |subtype| 37 | Type.new(typed_klass, dsl.coder, subtype) 38 | end 39 | end 40 | store_accessor(store_attribute, dsl.accessors.keys, **store_options) 41 | 42 | dsl.accessors.each do |accessor_name, accessor_key| 43 | define_method("#{accessor_key}_changed?") do 44 | send("#{store_attribute}_changed?") && 45 | send(store_attribute)[accessor_name] != send("#{store_attribute}_was")[accessor_name] 46 | end 47 | 48 | define_method("#{accessor_key}_was") do 49 | send("#{store_attribute}_was")[accessor_name] 50 | end 51 | 52 | define_method("restore_#{accessor_key}!") do 53 | send("#{accessor_key}=", send("#{accessor_name}_was")) 54 | end 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/active_record/typed_store/field.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord::TypedStore 4 | class Field 5 | attr_reader :array, :blank, :name, :default, :type, :null, :accessor, :type_sym 6 | 7 | def initialize(name, type, options={}) 8 | type_options = options.slice(:scale, :limit, :precision) 9 | @type = lookup_type(type, type_options) 10 | @type_sym = type 11 | 12 | @accessor = options.fetch(:accessor, true) 13 | @name = name 14 | @array = options.fetch(:array, false) 15 | if options.key?(:default) 16 | @default = extract_default(options[:default]) 17 | end 18 | @null = options.fetch(:null, true) 19 | @blank = options.fetch(:blank, true) 20 | end 21 | 22 | def has_default? 23 | defined?(@default) 24 | end 25 | 26 | def cast(value) 27 | casted_value = type_cast(value) 28 | if !blank 29 | casted_value = default if casted_value.blank? 30 | elsif array && has_default? 31 | casted_value = default if value.nil? 32 | elsif !null 33 | casted_value = default if casted_value.nil? 34 | end 35 | casted_value 36 | end 37 | 38 | private 39 | 40 | TYPES = { 41 | boolean: ::ActiveRecord::Type::Boolean, 42 | integer: ::ActiveRecord::Type::Integer, 43 | string: ::ActiveRecord::Type::String, 44 | float: ::ActiveRecord::Type::Float, 45 | date: ::ActiveRecord::Type::Date, 46 | time: ::ActiveRecord::Type::Time, 47 | datetime: ::ActiveRecord::Type::DateTime, 48 | decimal: ::ActiveRecord::Type::Decimal, 49 | any: ::ActiveRecord::Type::Value, 50 | } 51 | 52 | def lookup_type(type, options) 53 | TYPES.fetch(type).new(**options) 54 | end 55 | 56 | def extract_default(value) 57 | # 4.2 workaround 58 | return value if (type_sym == :string || type_sym == :text) && value.nil? 59 | 60 | type_cast(value) 61 | end 62 | 63 | def type_cast(value, arrayize: true) 64 | if array && (arrayize || value.is_a?(Array)) 65 | return [] if arrayize && !value.is_a?(Array) 66 | return value.map { |v| type_cast(v, arrayize: false) } 67 | end 68 | 69 | # 4.2 workaround 70 | if type_sym == :string || type_sym == :text 71 | return value.to_s unless value.blank? && (null || array) 72 | end 73 | 74 | if type.respond_to?(:cast) 75 | type.cast(value) 76 | else 77 | type.type_cast_from_user(value) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/active_record/typed_store/identity_coder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord::TypedStore 4 | module IdentityCoder 5 | extend self 6 | 7 | def load(data) 8 | data || {} 9 | end 10 | 11 | def dump(data) 12 | data || {} 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/active_record/typed_store/type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord::TypedStore 4 | class Type < ActiveRecord::Type::Serialized 5 | def initialize(typed_hash_klass, coder, subtype) 6 | @typed_hash_klass = typed_hash_klass 7 | super(subtype, coder) 8 | end 9 | 10 | [:deserialize, :type_cast_from_database, :type_cast_from_user].each do |method| 11 | define_method(method) do |value| 12 | if value.nil? 13 | hash = {} 14 | else 15 | hash = super(value) 16 | end 17 | 18 | @typed_hash_klass.new(hash) 19 | end 20 | end 21 | 22 | [:serialize, :type_cast_for_database].each do |method| 23 | define_method(method) do |value| 24 | return if value.nil? 25 | 26 | if value.respond_to?(:to_hash) 27 | super(value.to_hash) 28 | else 29 | raise ArgumentError, "ActiveRecord::TypedStore expects a hash as a column value, #{value.class} received" 30 | end 31 | end 32 | end 33 | 34 | def defaults 35 | @typed_hash_klass.defaults_hash 36 | end 37 | 38 | def default_value?(value) 39 | value == defaults 40 | end 41 | 42 | def changed_in_place?(raw_old_value, value) 43 | return false if value.nil? 44 | raw_new_value = serialize(value) 45 | raw_old_value.nil? != raw_new_value.nil? || raw_old_value != raw_new_value 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/active_record/typed_store/typed_hash.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord::TypedStore 4 | class TypedHash < HashWithIndifferentAccess 5 | 6 | class << self 7 | attr_reader :fields 8 | 9 | def create(fields) 10 | Class.new(self) do 11 | @fields = fields.index_by { |c| c.name.to_s } 12 | end 13 | end 14 | 15 | def defaults_hash 16 | Hash[fields.values.select(&:has_default?).map { |c| [c.name, c.default] }] 17 | end 18 | end 19 | 20 | delegate :with_indifferent_access, to: :to_h 21 | delegate :slice, :except, :without, to: :with_indifferent_access 22 | 23 | def initialize(constructor={}) 24 | super() 25 | update(defaults_hash) 26 | update(constructor.to_h) if constructor.respond_to?(:to_h) 27 | end 28 | 29 | def []=(key, value) 30 | super(key, cast_value(key, value)) 31 | end 32 | alias_method :store, :[]= 33 | 34 | def merge!(other_hash) 35 | other_hash.each_pair do |key, value| 36 | if block_given? && key?(key) 37 | value = yield(convert_key(key), self[key], value) 38 | end 39 | self[convert_key(key)] = convert_value(value) 40 | end 41 | self 42 | end 43 | alias_method :update, :merge! 44 | 45 | private 46 | 47 | delegate :fields, :defaults_hash, to: 'self.class' 48 | 49 | def cast_value(key, value) 50 | key = convert_key(key) 51 | field = fields[key] 52 | return value unless field 53 | 54 | casted_value = field.cast(value) 55 | 56 | if casted_value.nil? && !field.null && field.has_default? 57 | return field.default 58 | end 59 | 60 | casted_value 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/active_record/typed_store/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module TypedStore 5 | VERSION = '1.6.0' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/activerecord-typedstore.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record/typed_store' 4 | -------------------------------------------------------------------------------- /spec/active_record/typed_store/typed_hash_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe ActiveRecord::TypedStore::TypedHash do 4 | 5 | def create_hash_class(*args) 6 | described_class.create([ActiveRecord::TypedStore::Field.new(*args)]) 7 | end 8 | 9 | def build_hash(*args) 10 | create_hash_class(*args).new 11 | end 12 | 13 | let(:hash) { build_hash(*column) } 14 | 15 | let(:hash_class) { create_hash_class(*column) } 16 | 17 | context 'nullable column without default' do 18 | 19 | let(:column) { ['age', :integer] } 20 | 21 | describe '.new' do 22 | 23 | it 'apply casting' do 24 | hash = hash_class.new(age: '24') 25 | expect(hash[:age]).to be == 24 26 | end 27 | 28 | it "accepts hashy constructor" do 29 | object = double(to_h: { age: '24' }) 30 | hash = hash_class.new(object) 31 | expect(hash[:age]).to eq 24 32 | end 33 | end 34 | 35 | describe '#initialize' do 36 | 37 | it 'has nil as default value' do 38 | expect(hash[:age]).to be_nil 39 | end 40 | 41 | end 42 | 43 | describe '#[]=' do 44 | 45 | it 'apply casting' do 46 | hash[:age] = '24' 47 | expect(hash[:age]).to be == 24 48 | end 49 | 50 | it 'can be nil' do 51 | hash[:age] = nil 52 | expect(hash[:age]).to be_nil 53 | end 54 | 55 | end 56 | 57 | describe '#merge!' do 58 | 59 | it 'apply casting' do 60 | hash.merge!(age: '24') 61 | expect(hash[:age]).to be == 24 62 | end 63 | 64 | it 'can be nil' do 65 | hash.merge!(age: nil) 66 | expect(hash[:age]).to be_nil 67 | end 68 | 69 | end 70 | 71 | end 72 | 73 | context 'nullable column with default' do 74 | 75 | let(:column) { ['age', :integer, default: 42] } 76 | 77 | describe '#initialize' do 78 | 79 | it 'has the default value' do 80 | expect(hash[:age]).to be == 42 81 | end 82 | 83 | end 84 | 85 | describe '#[]=' do 86 | 87 | it 'apply casting' do 88 | hash[:age] = '24' 89 | expect(hash[:age]).to be == 24 90 | end 91 | 92 | it 'can be nil' do 93 | hash[:age] = nil 94 | expect(hash[:age]).to be_nil 95 | end 96 | 97 | end 98 | 99 | describe '#merge!' do 100 | 101 | it 'apply casting' do 102 | hash.merge!(age: '24') 103 | expect(hash[:age]).to be == 24 104 | end 105 | 106 | it 'can be nil' do 107 | hash.merge!(age: nil) 108 | expect(hash[:age]).to be_nil 109 | end 110 | 111 | end 112 | 113 | end 114 | 115 | context 'non nullable column with default' do 116 | 117 | let(:column) { ['age', :integer, null: false, default: 42] } 118 | 119 | describe '#intialize' do 120 | 121 | it 'has the default value' do 122 | expect(hash[:age]).to be == 42 123 | end 124 | 125 | end 126 | 127 | describe '#[]=' do 128 | 129 | it 'apply casting' do 130 | hash[:age] = '24' 131 | expect(hash[:age]).to be == 24 132 | end 133 | 134 | it 'cannot be nil' do 135 | hash[:age] = nil 136 | expect(hash[:age]).to be == 42 137 | end 138 | 139 | end 140 | 141 | describe '#merge!' do 142 | 143 | it 'apply casting' do 144 | hash.merge!(age: '24') 145 | expect(hash[:age]).to be == 24 146 | end 147 | 148 | it 'cannot be nil' do 149 | hash.merge!(age: nil) 150 | expect(hash[:age]).to be == 42 151 | end 152 | 153 | end 154 | 155 | end 156 | 157 | context 'non blankable column with default' do 158 | 159 | let(:column) { ['source', :string, blank: false, default: 'web'] } 160 | 161 | describe '#intialize' do 162 | 163 | it 'has the default value' do 164 | expect(hash[:source]).to be == 'web' 165 | end 166 | 167 | end 168 | 169 | describe '#[]=' do 170 | 171 | it 'apply casting' do 172 | hash[:source] = :mailing 173 | expect(hash[:source]).to be == 'mailing' 174 | end 175 | 176 | it 'cannot be nil' do 177 | hash[:source] = nil 178 | expect(hash[:source]).to be == 'web' 179 | end 180 | 181 | it 'cannot be blank' do 182 | hash[:source] = '' 183 | expect(hash[:source]).to be == 'web' 184 | end 185 | 186 | end 187 | 188 | describe '#merge!' do 189 | 190 | it 'apply casting' do 191 | hash.merge!(source: :mailing) 192 | expect(hash[:source]).to be == 'mailing' 193 | end 194 | 195 | it 'cannot be nil' do 196 | hash.merge!(source: nil) 197 | expect(hash[:source]).to be == 'web' 198 | end 199 | 200 | it 'cannot be blank' do 201 | hash.merge!(source: '') 202 | expect(hash[:source]).to be == 'web' 203 | end 204 | 205 | end 206 | 207 | describe '#except' do 208 | 209 | it 'does not set the default for ignored keys' do 210 | hash = hash_class.new(source: 'foo') 211 | expect(hash.except(:source)).to_not have_key(:source) 212 | end 213 | 214 | end 215 | 216 | describe '#slice' do 217 | 218 | it 'does not set the default for ignored keys' do 219 | hash = hash_class.new(source: 'foo') 220 | expect(hash.slice(:not_source)).to_not have_key(:source) 221 | end 222 | 223 | end 224 | 225 | end 226 | 227 | context 'unknown columns' do 228 | let(:column) { ['age', :integer] } 229 | 230 | it 'can be assigned' do 231 | hash = hash_class.new 232 | hash[:unknown_key] = 42 233 | expect(hash[:unknown_key]).to be == 42 234 | end 235 | end 236 | end 237 | -------------------------------------------------------------------------------- /spec/active_record/typed_store_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | shared_examples 'any model' do 4 | 5 | let(:model) { described_class.new } 6 | 7 | describe 'reset_column_information' do 8 | 9 | it 'do not definitely undefine attributes' do 10 | expect(model.age).to be_present 11 | expect(model.age_changed?).to be_falsey 12 | 13 | described_class.reset_column_information 14 | 15 | model = described_class.new 16 | expect(model.age).to be_present 17 | expect(model.age_changed?).to be_falsey 18 | end 19 | 20 | end 21 | 22 | describe 'Marshal.dump' do 23 | 24 | it 'dumps the model' do 25 | Marshal.dump(model) 26 | end 27 | 28 | end 29 | 30 | describe 'regular AR::Store' do 31 | 32 | it 'save attributes as usual' do 33 | model.update(title: 'The Big Lebowski') 34 | expect(model.reload.title).to be == 'The Big Lebowski' 35 | end 36 | 37 | end 38 | 39 | describe 'build' do 40 | 41 | it 'assign attributes received by #initialize' do 42 | model = described_class.new(public: true) 43 | expect(model.public).to be true 44 | end 45 | 46 | end 47 | 48 | describe 'dirty tracking' do 49 | it 'track changed attributes' do 50 | expect(model.age_changed?).to be_falsey 51 | model.age = 24 52 | expect(model.age_changed?).to be_truthy 53 | end 54 | 55 | it 'keep track of what the attribute was' do 56 | model.age = 24 57 | expect(model.age_was).to eq 12 58 | end 59 | 60 | it 'keep track of the whole changes' do 61 | expect { 62 | model.age = 24 63 | }.to change { model.changes['age'] }.from(nil).to([12, 24]) 64 | end 65 | 66 | it 'reset recorded changes after successful save' do 67 | model.age = 24 68 | expect { 69 | model.save 70 | }.to change { !!model.age_changed? }.from(true).to(false) 71 | end 72 | 73 | it 'can be restored individually' do 74 | model.age = 24 75 | expect { 76 | model.restore_age! 77 | }.to change { model.age }.from(24).to(12) 78 | end 79 | 80 | it 'does not dirty track assigning the same boolean' do 81 | expect(model.enabled).to be true 82 | expect { 83 | model.enabled = true 84 | }.to_not change { model.enabled_changed? } 85 | end 86 | 87 | it 'dirty tracks when the boolean changes' do 88 | expect(model.enabled).to be true 89 | expect { 90 | model.enabled = false 91 | }.to change { !!model.enabled_changed? }.from(false).to(true) 92 | end 93 | 94 | it 'does not dirty track assigning the same boolean even if it is a string' do 95 | expect(model.enabled).to be true 96 | expect { 97 | model.enabled = "true" 98 | }.to_not change { model.enabled_changed? } 99 | end 100 | 101 | it 'dirty tracks when the string changes' do 102 | expect { 103 | model.name = "Smith" 104 | }.to change { !!model.name_changed? }.from(false).to(true) 105 | end 106 | 107 | it 'does not dirty track assigning the same string' do 108 | expect { 109 | model.name = "" 110 | }.to_not change { !!model.name_changed? } 111 | end 112 | end 113 | 114 | describe 'unknown attribute' do 115 | 116 | it 'raise an ActiveRecord::UnknownAttributeError on save attemps' do 117 | expect { 118 | model.update(unknown_attribute: 42) 119 | }.to raise_error ActiveRecord::UnknownAttributeError 120 | end 121 | 122 | it 'raise a NoMethodError on assignation attemps' do 123 | expect { 124 | model.unknown_attribute = 42 125 | }.to raise_error NoMethodError 126 | end 127 | 128 | end 129 | 130 | describe 'all attributes' do 131 | it 'is initialized at nil if :default is not defined' do 132 | expect(model.no_default).to be_nil 133 | end 134 | 135 | it 'is accessible throught #read_attribute' do 136 | model.name = 'foo' 137 | expect(model.read_attribute(:name)).to be == 'foo' 138 | end 139 | 140 | it 'is accessible through #read_attribute when attribute is nil' do 141 | expect(model.read_attribute(nil)).to be_nil 142 | end 143 | 144 | it 'allows #increment! when attribute is nil' do 145 | expect { model.increment!(nil) }.to raise_error(ActiveModel::MissingAttributeError) 146 | end 147 | end 148 | 149 | describe 'string attribute' do 150 | 151 | it 'has the defined default as initial value' do 152 | expect(model.name).to be == '' 153 | end 154 | 155 | it 'default to nil if specified explicitly' do 156 | expect(model.cell_phone).to be_nil 157 | end 158 | 159 | it 'properly cast the value as string' do 160 | model.update(name: 42) 161 | expect(model.reload.name).to be == '42' 162 | end 163 | 164 | it 'any string is considered present' do 165 | model.name = 'Peter Gibbons' 166 | expect(model.name?).to be true 167 | end 168 | 169 | it 'empty string is not considered present' do 170 | expect(model.name?).to be false 171 | end 172 | 173 | it 'nil is not considered present' do 174 | expect(model.cell_phone?).to be false 175 | end 176 | 177 | it 'not define the attributes more than one time' do 178 | model.respond_to?(:foo) 179 | expect(described_class).to receive(:define_virtual_attribute_method).never 180 | model.respond_to?(:foobar) 181 | end 182 | end 183 | 184 | describe 'boolean attribute' do 185 | 186 | it 'has the defined :default as initial value' do 187 | expect(model.public).to be false 188 | end 189 | 190 | [true, 1, '1', 't', 'T', 'true', 'TRUE', 'on', 'ON'].each do |value| 191 | 192 | it "cast `#{value.inspect}` as `true`" do 193 | model.public = value 194 | expect(model.public).to be true 195 | end 196 | 197 | end 198 | 199 | [false, 0, '0', 'f', 'F', 'false', 'FALSE', 'off', 'OFF'].each do |value| 200 | 201 | it "cast `#{value.inspect}` as `false`" do 202 | model.public = value 203 | expect(model.public).to be false 204 | expect(model.public?).to be false 205 | end 206 | 207 | end 208 | 209 | it 'properly persit the value' do 210 | model.update(public: false) 211 | expect(model.reload.public).to be false 212 | model.update(public: true) 213 | expect(model.reload.public).to be true 214 | end 215 | 216 | it 'initialize with default value if the column is not nullable' do 217 | expect(model.public).to be false 218 | model.save 219 | expect(model.reload.public).to be false 220 | end 221 | 222 | it 'can store nil if the column is nullable' do 223 | model.update(enabled: nil) 224 | expect(model.reload.enabled).to be_nil 225 | end 226 | 227 | it 'save the default value if the column is nullable but the value not explictly set' do 228 | model.save 229 | expect(model.reload.enabled).to be true 230 | end 231 | 232 | it 'true is considered present' do 233 | expect(model.enabled?).to be true 234 | end 235 | 236 | it 'false is not considered present' do 237 | expect(model.public?).to be false 238 | end 239 | 240 | it 'nil is not considered present' do 241 | model.update(enabled: nil) 242 | expect(model.enabled?).to be false 243 | end 244 | 245 | end 246 | 247 | describe 'integer attributes' do 248 | 249 | it 'has the defined default as initial value' do 250 | expect(model.age).to be == 12 251 | end 252 | 253 | it 'properly cast assigned value to integer' do 254 | model.age = '42' 255 | expect(model.age).to be == 42 256 | end 257 | 258 | it 'properly cast non numeric values to integer' do 259 | model.age = 'foo' 260 | expect(model.age).to be == 0 261 | end 262 | 263 | it 'can store nil if the column is nullable' do 264 | model.update(max_length: nil) 265 | expect(model.reload.max_length).to be_nil 266 | end 267 | 268 | it 'positive values are considered present' do 269 | expect(model.age?).to be true 270 | end 271 | 272 | it 'negative values are considered present' do 273 | model.age = -42 274 | expect(model.age?).to be true 275 | end 276 | 277 | it '0 is not considered present' do 278 | model.age = 0 279 | expect(model.age?).to be false 280 | end 281 | 282 | it 'nil is not considered present' do 283 | model.max_length = nil 284 | expect(model.max_length?).to be false 285 | end 286 | 287 | end 288 | 289 | describe 'float attributes' do 290 | 291 | it 'has the defined default as initial value' do 292 | expect(model.rate).to be_zero 293 | end 294 | 295 | it 'properly cast assigned value to float' do 296 | model.rate = '4.2' 297 | expect(model.rate).to be == 4.2 298 | end 299 | 300 | it 'properly cast non numeric values to float' do 301 | model.rate = 'foo' 302 | expect(model.rate).to be == 0 303 | end 304 | 305 | it 'can store nil if the column is nullable' do 306 | model.update(price: nil) 307 | expect(model.reload.price).to be_nil 308 | end 309 | 310 | it 'positive values are considered present' do 311 | model.rate = 4.2 312 | expect(model.rate?).to be true 313 | end 314 | 315 | it 'negative values are considered present' do 316 | model.rate = -4.2 317 | expect(model.rate?).to be true 318 | end 319 | 320 | it '0 is not considered present' do 321 | expect(model.rate?).to be false 322 | end 323 | 324 | it 'nil is not considered present' do 325 | expect(model.price?).to be false 326 | end 327 | 328 | end 329 | 330 | describe 'decimal attributes' do 331 | 332 | it 'has the defined default as initial value' do 333 | expect(model.total_price).to be == BigDecimal('4.2') 334 | expect(model.total_price).to be_a BigDecimal 335 | end 336 | 337 | it 'properly cast assigned value to decimal' do 338 | model.shipping_cost = 4.2 339 | expect(model.shipping_cost).to be == BigDecimal('4.2') 340 | expect(model.shipping_cost).to be_a BigDecimal 341 | end 342 | 343 | it 'properly cast non numeric values to decimal' do 344 | model.total_price = 'foo' 345 | expect(model.total_price).to be == 0 346 | expect(model.total_price).to be_a BigDecimal 347 | end 348 | 349 | it 'retreive a BigDecimal instance' do 350 | model.update(shipping_cost: 4.2) 351 | expect(model.reload.shipping_cost).to be == BigDecimal('4.2') 352 | expect(model.reload.shipping_cost).to be_a BigDecimal 353 | end 354 | 355 | it 'can store nil if the column is nullable' do 356 | model.update(shipping_cost: nil) 357 | expect(model.reload.shipping_cost).to be_nil 358 | end 359 | 360 | it 'positive values are considered present' do 361 | model.shipping_cost = BigDecimal('4.2') 362 | expect(model.shipping_cost?).to be true 363 | end 364 | 365 | it 'negative values are considered present' do 366 | model.shipping_cost = BigDecimal('-4.2') 367 | expect(model.shipping_cost?).to be true 368 | end 369 | 370 | it '0 is not considered present' do 371 | model.shipping_cost = BigDecimal('0') 372 | expect(model.shipping_cost?).to be false 373 | end 374 | 375 | it 'nil is not considered present' do 376 | expect(model.shipping_cost?).to be false 377 | end 378 | 379 | end 380 | 381 | describe 'date attributes' do 382 | 383 | let(:date) { Date.new(1984, 6, 8) } 384 | 385 | it 'has the defined default as initial value' do 386 | expect(model.published_on).to be == date 387 | end 388 | 389 | it 'properly cast assigned value to date' do 390 | model.remind_on = '1984-06-08' 391 | expect(model.remind_on).to be == date 392 | end 393 | 394 | it 'retreive a Date instance' do 395 | model.update(published_on: date) 396 | expect(model.reload.published_on).to be == date 397 | end 398 | 399 | it 'nillify unparsable dates' do 400 | model.update(remind_on: 'foo') 401 | expect(model.remind_on).to be_nil 402 | end 403 | 404 | it 'can store nil if the column is nullable' do 405 | model.update(remind_on: nil) 406 | expect(model.reload.remind_on).to be_nil 407 | end 408 | 409 | it 'any non-nil value is considered present' do 410 | model.remind_on = Date.new 411 | expect(model.remind_on?).to be true 412 | end 413 | 414 | it 'nil is not considered present' do 415 | expect(model.remind_on?).to be false 416 | end 417 | 418 | end 419 | 420 | describe 'time attributes' do 421 | let(:time) { Time.new(1984, 6, 8, 13, 57, 12) } 422 | let(:time_string) { '1984-06-08 13:57:12' } 423 | let(:time) { time_string.respond_to?(:in_time_zone) ? time_string.in_time_zone : Time.parse(time_string) } 424 | 425 | context "with ActiveRecord #{ActiveRecord::VERSION::STRING}" do 426 | it 'has the defined default as initial value' do 427 | model.save 428 | expect(model.reload.published_at).to be == time 429 | end 430 | 431 | it 'retreive a time instance' do 432 | model.update(published_at: time) 433 | expect(model.reload.published_at).to be == time 434 | end 435 | 436 | if ActiveRecord::Base.time_zone_aware_attributes 437 | it 'properly cast assigned value to time' do 438 | model.remind_at = time_string 439 | expect(model.remind_at).to be == time 440 | end 441 | else 442 | it 'properly cast assigned value to time' do 443 | model.remind_at = time_string 444 | expect(model.remind_at).to be == time 445 | end 446 | end 447 | end 448 | end 449 | 450 | describe 'datetime attributes' do 451 | 452 | let(:datetime) { DateTime.new(1984, 6, 8, 13, 57, 12) } 453 | let(:datetime_string) { '1984-06-08 13:57:12' } 454 | let(:time) { datetime_string.respond_to?(:in_time_zone) ? datetime_string.in_time_zone : Time.parse(datetime_string) } 455 | 456 | context "with ActiveRecord #{ActiveRecord::VERSION::STRING}" do 457 | it 'has the defined default as initial value' do 458 | model.save 459 | expect(model.reload.published_at).to be == datetime 460 | end 461 | 462 | it 'retreive a DateTime instance' do 463 | model.update(published_at: datetime) 464 | expect(model.reload.published_at).to be == datetime 465 | end 466 | 467 | if ActiveRecord::Base.time_zone_aware_attributes 468 | it 'properly cast assigned value to time' do 469 | model.remind_at = datetime_string 470 | expect(model.remind_at).to be == time 471 | end 472 | else 473 | it 'properly cast assigned value to datetime' do 474 | model.remind_at = datetime_string 475 | expect(model.remind_at).to be == datetime 476 | end 477 | end 478 | end 479 | 480 | it 'nillify unparsable datetimes' do 481 | model.update(remind_at: 'foo') 482 | expect(model.remind_at).to be_nil 483 | end 484 | 485 | it 'can store nil if the column is nullable' do 486 | model.update(remind_at: nil) 487 | expect(model.reload.remind_at).to be_nil 488 | end 489 | 490 | it 'any non-nil value is considered present' do 491 | model.remind_at = DateTime.new 492 | expect(model.remind_at?).to be true 493 | end 494 | 495 | it 'nil is not considered present' do 496 | expect(model.remind_at?).to be false 497 | end 498 | 499 | end 500 | 501 | end 502 | 503 | shared_examples 'a store' do |retain_type = true, settings_type = :text| 504 | let(:model) { described_class.new } 505 | 506 | describe "without connection" do 507 | before do 508 | $conn_params = ActiveRecord::Base.remove_connection 509 | end 510 | after do 511 | ActiveRecord::Base.establish_connection $conn_params 512 | end 513 | 514 | it "does not require a connection to initialize a model" do 515 | klass = Class.new(ActiveRecord::Base) do 516 | typed_store :settings do |t| 517 | t.integer :age 518 | end 519 | end 520 | expect(klass.connected?).to be_falsy 521 | end 522 | end 523 | 524 | describe 'model.typed_stores' do 525 | it "can access keys" do 526 | stores = model.class.typed_stores 527 | expect(stores[:settings].keys).to eq [:no_default, :name, :email, :cell_phone, :public, :enabled, :age, :max_length, :rate, :price, :published_on, :remind_on, :published_at_time, :remind_at_time, :published_at, :remind_at, :total_price, :shipping_cost, :grades, :tags, :subjects, :nickname, :author, :source, :signup, :country] 528 | end 529 | 530 | it "can access keys even when accessors are not defined" do 531 | stores = model.class.typed_stores 532 | expect(stores[:explicit_settings].keys).to eq [:ip_address, :user_agent, :signup] 533 | end 534 | 535 | it "can access keys even when accessors are partially defined" do 536 | stores = model.class.typed_stores 537 | expect(stores[:partial_settings].keys).to eq [:tax_rate_key, :tax_rate] 538 | end 539 | end 540 | 541 | it 'does not include blank attribute' do 542 | expect(model.settings).not_to have_key(:remind_on) 543 | model.settings = { extra_key: 123 } 544 | model.save! 545 | expect(model.settings).not_to have_key(:remind_on) 546 | end 547 | 548 | describe 'assigning the store' do 549 | it 'handles mutated value' do 550 | model.save! 551 | model.settings[:signup][:apps] ||= [] 552 | model.settings[:signup][:apps] << 123 553 | expect(model.settings[:signup]).to eq ({ 554 | "apps" => [123] 555 | }) 556 | expect(model.settings_changed?).to eq true 557 | end 558 | 559 | it 'coerce it to the proper typed hash' do 560 | expect { 561 | model.settings = {} 562 | }.to_not change { model.settings.class } 563 | end 564 | 565 | it 'still handle default values' do 566 | expect { 567 | model.settings = {} 568 | }.to_not change { model.settings['nickname'] } 569 | end 570 | 571 | it 'has indifferent accessor' do 572 | expect(model.settings[:age]).to eq model.settings['age'] 573 | model.settings['age'] = "40" 574 | expect(model.settings[:age]).to eq 40 575 | end 576 | 577 | it 'does not crash on decoding non-hash store value' do 578 | expect { 579 | model.settings = String.new 580 | model.settings 581 | }.to raise_error ArgumentError, "ActiveRecord::TypedStore expects a hash as a column value, String received" 582 | 583 | expect { 584 | model.settings = String.new 585 | model.save! 586 | }.to raise_error ArgumentError, "ActiveRecord::TypedStore expects a hash as a column value, String received" 587 | end 588 | 589 | it 'does not crash if column is nil' do 590 | model.save! 591 | model.update_column(:settings, nil) 592 | 593 | model.reload 594 | expect(model.settings).to be_present 595 | end 596 | 597 | it 'allows to assign custom key' do 598 | model.settings[:not_existing_key] = 42 599 | expect(model.settings[:not_existing_key]).to eq 42 600 | model.save! 601 | 602 | model.reload 603 | expect(model.settings[:not_existing_key]).to eq 42 604 | end 605 | 606 | it 'delegates internal methods to the underlying type' do 607 | expect(model.class.type_for_attribute("settings").type).to eq settings_type 608 | end 609 | end 610 | 611 | describe 'attributes' do 612 | 613 | it 'retrieve default if assigned nil and null not allowed' do 614 | model.update(age: nil) 615 | expect(model.age).to be == 12 616 | end 617 | 618 | context 'when column cannot be blank' do 619 | it 'retreive default if not persisted yet, and nothing was assigned' do 620 | expect(model.nickname).to be == 'Please enter your nickname' 621 | end 622 | 623 | it 'retreive default if assigned a blank value' do 624 | model.update(nickname: '') 625 | expect(model.nickname).to be == 'Please enter your nickname' 626 | expect(model.reload.nickname).to be == 'Please enter your nickname' 627 | end 628 | 629 | end 630 | 631 | it 'do not respond to _before_type_cast' do 632 | expect(model).to_not respond_to :nickname_before_type_cast 633 | end 634 | 635 | end 636 | 637 | describe 'attributes without accessors' do 638 | 639 | it 'cannot be accessed as a model attribute' do 640 | expect(model).to_not respond_to :country 641 | expect(model).to_not respond_to :country= 642 | end 643 | 644 | it 'cannot be queried' do 645 | expect(model).to_not respond_to :country? 646 | end 647 | 648 | it 'cannot be reset' do 649 | expect(model).to_not respond_to :reset_country! 650 | end 651 | 652 | it 'does not have dirty accessors' do 653 | expect(model).not_to respond_to :country_was 654 | end 655 | 656 | it 'still has casting a default handling' do 657 | expect(model.settings[:country]).to be == 'Canada' 658 | end 659 | 660 | end 661 | 662 | describe 'with no accessors' do 663 | 664 | it 'cannot be accessed as a model attribute' do 665 | expect(model).not_to respond_to :ip_address 666 | expect(model).not_to respond_to :ip_address= 667 | end 668 | 669 | it 'cannot be queried' do 670 | expect(model).not_to respond_to :ip_address? 671 | end 672 | 673 | it 'cannot be reset' do 674 | expect(model).not_to respond_to :reset_ip_address! 675 | end 676 | 677 | it 'does not have dirty accessors' do 678 | expect(model).not_to respond_to :ip_address_was 679 | end 680 | 681 | it 'still has casting a default handling' do 682 | expect(model.explicit_settings[:ip_address]).to be == '127.0.0.1' 683 | end 684 | 685 | end 686 | 687 | describe 'with some accessors' do 688 | 689 | it 'does not define an attribute' do 690 | expect(model).not_to respond_to :tax_rate_key 691 | end 692 | 693 | it 'define an attribute when included in the accessors array' do 694 | expect(model).to respond_to :tax_rate 695 | end 696 | 697 | end 698 | 699 | describe 'with prefix true' do 700 | 701 | it 'defines prefixed accessors' do 702 | expect(model).to respond_to :prefixed_settings_language 703 | expect(model).to respond_to :prefixed_settings_language= 704 | end 705 | 706 | it 'does not define unprefixed accessors' do 707 | expect(model).not_to respond_to :language 708 | expect(model).not_to respond_to :language= 709 | end 710 | 711 | it 'can be updated' do 712 | model.update(prefixed_settings_language: 'en') 713 | expect(model.reload.prefixed_settings_language).to be == 'en' 714 | end 715 | 716 | end 717 | 718 | describe 'with custom prefix' do 719 | 720 | it 'defines prefixed accessors' do 721 | expect(model).to respond_to :custom_language 722 | expect(model).to respond_to :custom_language= 723 | end 724 | 725 | it 'does not define unprefixed accessors' do 726 | expect(model).not_to respond_to :language 727 | expect(model).not_to respond_to :language= 728 | end 729 | 730 | it 'can be updated' do 731 | model.update(custom_language: 'en') 732 | expect(model.reload.custom_language).to be == 'en' 733 | end 734 | 735 | end 736 | 737 | describe 'with suffix true' do 738 | 739 | it 'defines suffixed accessors' do 740 | expect(model).to respond_to :language_suffixed_settings 741 | expect(model).to respond_to :language_suffixed_settings= 742 | end 743 | 744 | it 'does not define unprefixed accessors' do 745 | expect(model).not_to respond_to :language 746 | expect(model).not_to respond_to :language= 747 | end 748 | 749 | it 'can be updated' do 750 | model.update(language_suffixed_settings: 'en') 751 | expect(model.reload.language_suffixed_settings).to be == 'en' 752 | end 753 | 754 | end 755 | 756 | describe 'with custom suffix' do 757 | 758 | it 'defines suffixed accessors' do 759 | expect(model).to respond_to :language_custom 760 | expect(model).to respond_to :language_custom= 761 | end 762 | 763 | it 'does not define unprefixed accessors' do 764 | expect(model).not_to respond_to :language 765 | expect(model).not_to respond_to :language= 766 | end 767 | 768 | it 'can be updated' do 769 | model.update(language_custom: 'en') 770 | expect(model.reload.language_custom).to be == 'en' 771 | end 772 | 773 | end 774 | 775 | describe '`any` attributes' do 776 | 777 | it 'accept any type' do 778 | model.update(author: 'George') 779 | expect(model.reload.author).to be == 'George' 780 | 781 | model.update(author: 42) 782 | expect(model.reload.author).to be == (retain_type ? 42 : '42') 783 | end 784 | 785 | it 'still handle default' do 786 | model.update(source: '') 787 | expect(model.reload.source).to be == 'web' 788 | end 789 | 790 | it 'works with default hash' do 791 | model.signup[:counter] = 123 792 | model.save! 793 | expect(model.settings[:signup][:counter]).to eq 123 794 | end 795 | 796 | it 'works with default hash without affecting unaccessible attributes' do 797 | model.signup[:counter] = 123 798 | model.save! 799 | expect(model.explicit_settings[:signup][:counter]).to be_nil 800 | end 801 | 802 | it 'coerce hashes to HashWithIndifferentAccess' do # this is actually Rails behavior 803 | model.signup[:metadata] = { "signed_up_at" => Time.now } 804 | expect(model.signup[:metadata]).to be_a ActiveSupport::HashWithIndifferentAccess 805 | end 806 | 807 | end 808 | end 809 | 810 | shared_examples 'a db backed model' do 811 | 812 | let(:model) { described_class.new } 813 | 814 | it 'let the underlying db raise if assigned nil on non nullable column' do 815 | expect { 816 | model.update(age: nil) 817 | }.to raise_error(ActiveRecord::StatementInvalid) 818 | end 819 | 820 | describe "#write_attribute" do 821 | let(:value) { 12 } 822 | it "attr_name can be a string" do 823 | model.send(:write_attribute, 'age', value) 824 | expect(model.age).to be == value 825 | end 826 | 827 | it "attr_name can be a symbol" do 828 | model.send(:write_attribute, :age, value) 829 | expect(model.age).to be == value 830 | end 831 | end 832 | 833 | end 834 | 835 | shared_examples 'a model supporting arrays' do |pg_native=false| 836 | 837 | let(:model) { described_class.new } 838 | 839 | it 'retrieve an array of values' do 840 | model.update(grades: [1, 2, 3, 4]) 841 | expect(model.reload.grades).to be == [1, 2, 3, 4] 842 | end 843 | 844 | it 'cast values inside the array (integer)' do 845 | model.update(grades: ['1', 2, 3.4]) 846 | expect(model.reload.grades).to be == [1, 2, 3] 847 | end 848 | 849 | it 'cast values inside the array (string)' do 850 | model.update(tags: [1, 2.3]) 851 | expect(model.reload.tags).to be == %w(1 2.3) 852 | end 853 | 854 | it 'accept nil inside array even if collumn is non nullable' do 855 | model.update(tags: [1, nil]) 856 | expect(model.reload.tags).to be == ['1', nil] 857 | end 858 | 859 | if !pg_native 860 | it 'convert non array value as empty array' do 861 | model.update(grades: 'foo') 862 | expect(model.reload.grades).to be == [] 863 | end 864 | 865 | it 'accept multidimensianl arrays' do 866 | model.update(grades: [[1, 2], [3, 4]]) 867 | expect(model.reload.grades).to be == [[1, 2], [3, 4]] 868 | end 869 | end 870 | 871 | if pg_native 872 | 873 | it 'raise on non rectangular multidimensianl arrays' do 874 | expect{ 875 | model.update(grades: [[1, 2], [3, 4, 5]]) 876 | }.to raise_error(ActiveRecord::StatementInvalid) 877 | end 878 | 879 | it 'raise on non nil assignation if column is non nullable' do 880 | expect{ 881 | model.update(tags: nil) 882 | }.to raise_error(ActiveRecord::StatementInvalid) 883 | end 884 | 885 | else 886 | 887 | it 'accept non rectangular multidimensianl arrays' do 888 | model.update(grades: [[1, 2], [3, 4, 5]]) 889 | expect(model.reload.grades).to be == [[1, 2], [3, 4, 5]] 890 | end 891 | 892 | it 'defaults to [] if provided default is not an array' do 893 | model.update(subjects: nil) 894 | expect(model.reload.subjects).to be == [] 895 | end 896 | 897 | # Not sure about pg_native and if this test should be outside of this block. 898 | it 'retreive default if assigned null' do 899 | model.update(tags: nil) 900 | expect(model.reload.tags).to be == ['article'] 901 | end 902 | end 903 | end 904 | 905 | describe Sqlite3RegularARModel do 906 | it_should_behave_like 'any model' 907 | it_should_behave_like 'a db backed model' 908 | end 909 | 910 | describe MysqlRegularARModel do 911 | it_should_behave_like 'any model' 912 | it_should_behave_like 'a db backed model' 913 | end if defined?(MysqlRegularARModel) 914 | 915 | describe PostgresqlRegularARModel do 916 | it_should_behave_like 'any model' 917 | it_should_behave_like 'a db backed model' 918 | it_should_behave_like 'a model supporting arrays', true 919 | end if defined?(PostgresqlRegularARModel) 920 | 921 | describe PostgresJsonTypedStoreModel do 922 | it_should_behave_like 'any model' 923 | it_should_behave_like 'a store', true, :json 924 | it_should_behave_like 'a model supporting arrays' 925 | end if defined?(PostgresJsonTypedStoreModel) 926 | 927 | describe YamlTypedStoreModel do 928 | it_should_behave_like 'any model' 929 | it_should_behave_like 'a store' 930 | it_should_behave_like 'a model supporting arrays' 931 | 932 | it 'nested hashes are not serialized as HashWithIndifferentAccess' do 933 | model = described_class.create! 934 | expect(model.settings_before_type_cast.to_s).not_to include('HashWithIndifferentAccess') 935 | end 936 | end 937 | 938 | describe JsonTypedStoreModel do 939 | it_should_behave_like 'any model' 940 | it_should_behave_like 'a store' 941 | it_should_behave_like 'a model supporting arrays' 942 | end 943 | 944 | describe MarshalTypedStoreModel do 945 | it_should_behave_like 'any model' 946 | it_should_behave_like 'a store' 947 | it_should_behave_like 'a model supporting arrays' 948 | end 949 | 950 | describe InheritedTypedStoreModel do 951 | let(:model) { described_class.new } 952 | 953 | it 'can be serialized' do 954 | model.update(new_attribute: "foobar") 955 | expect(model.reload.new_attribute).to be == "foobar" 956 | end 957 | 958 | it 'is casted' do 959 | model.update(new_attribute: 42) 960 | expect(model.settings[:new_attribute]).to be == '42' 961 | end 962 | end 963 | 964 | describe DirtyTrackingModel do 965 | it 'stores the default on creation' do 966 | model = DirtyTrackingModel.create! 967 | model = DirtyTrackingModel.find(model.id) 968 | expect(model.settings_before_type_cast).to_not be_blank 969 | end 970 | 971 | it 'handles loaded records having uninitialized defaults' do 972 | model = DirtyTrackingModel.create! 973 | DirtyTrackingModel.update_all("settings = NULL") # bypass validation 974 | model = DirtyTrackingModel.find(model.id) 975 | expect(model.settings_changed?).to be false 976 | expect(model.changes).to be_empty 977 | 978 | model.update!(title: "Hello") 979 | 980 | expect(model.settings_changed?).to be false 981 | expect(model.changes).to be_empty 982 | end 983 | 984 | it 'does not update missing attributes in partially loaded records' do 985 | model = DirtyTrackingModel.create!(active: true) 986 | model = DirtyTrackingModel.select(:id, :title).find(model.id) 987 | model.update!(title: "Hello") 988 | 989 | model = DirtyTrackingModel.find(model.id) 990 | expect(model.active).to be true 991 | end 992 | end 993 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | 4 | require 'database_cleaner' 5 | require 'activerecord-typedstore' 6 | 7 | Dir[File.expand_path(File.join(File.dirname(__FILE__), 'support', '**', '*.rb'))].each { |f| require f } 8 | 9 | Time.zone = 'UTC' 10 | 11 | if ActiveRecord.respond_to?(:yaml_column_permitted_classes) 12 | ActiveRecord.yaml_column_permitted_classes |= [Date, Time, BigDecimal] 13 | elsif ActiveRecord::Base.respond_to?(:yaml_column_permitted_classes) 14 | ActiveRecord::Base.yaml_column_permitted_classes |= [Date, Time, BigDecimal] 15 | end 16 | 17 | RSpec.configure do |config| 18 | config.order = 'random' 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/database_cleaner.rb: -------------------------------------------------------------------------------- 1 | DatabaseCleaner[:active_record].strategy = :transaction 2 | 3 | RSpec.configure do |config| 4 | config.before :suite do 5 | DatabaseCleaner.clean_with :transaction 6 | end 7 | config.before :each do 8 | DatabaseCleaner.start 9 | end 10 | config.after :each do 11 | DatabaseCleaner.clean 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'base64' 3 | require 'json' 4 | require 'yaml' 5 | 6 | ENV["RAILS_ENV"] = "test" 7 | 8 | ActiveRecord::Base.time_zone_aware_attributes = ENV['TIMEZONE_AWARE'] != '0' 9 | credentials = { 'database' => 'typed_store_test', 'username' => 'typed_store', 'password' => 'typed_store' } 10 | ActiveRecord::Base.configurations = { 11 | test: { 12 | 'test_sqlite3' => { 'adapter' => 'sqlite3', 'database' => '/tmp/typed_store.db' }, 13 | } 14 | } 15 | 16 | def define_columns(t, array: false) 17 | t.integer :no_default 18 | 19 | t.string :name, default: '', null: false 20 | t.string :email 21 | t.string :cell_phone, default: nil 22 | 23 | t.boolean :public, default: false, null: false 24 | t.boolean :enabled, default: true 25 | 26 | t.integer :age, default: 12, null: false 27 | t.integer :max_length 28 | 29 | t.float :rate, default: 0, null: false 30 | t.float :price 31 | 32 | t.date :published_on, default: '1984-06-08', null: false 33 | t.date :remind_on 34 | 35 | t.time :published_at_time, default: '1984-06-08 13:57:12', null: false 36 | t.time :remind_at_time 37 | 38 | t.datetime :published_at, default: '1984-06-08 13:57:12', null: false 39 | t.datetime :remind_at 40 | 41 | t.decimal :total_price, default: 4.2, null: false, precision: 16, scale: 2 42 | t.decimal :shipping_cost, precision: 16, scale: 2 43 | 44 | if t.is_a?(ActiveRecord::TypedStore::DSL) 45 | t.integer :grades, array: true 46 | t.string :tags, array: true, null: false, default: ['article'] 47 | t.string :subjects, array: true, null: false, default: ['mathematics'].to_yaml 48 | 49 | t.string :nickname, blank: false, default: 'Please enter your nickname' 50 | end 51 | end 52 | 53 | def define_store_with_no_attributes(**options) 54 | typed_store :explicit_settings, accessors: false, **options do |t| 55 | t.string :ip_address, default: '127.0.0.1' 56 | t.string :user_agent 57 | t.any :signup, default: {} 58 | end 59 | end 60 | 61 | def define_store_with_partial_attributes(**options) 62 | typed_store :partial_settings, accessors: [:tax_rate], **options do |t| 63 | t.string :tax_rate_key 64 | t.string :tax_rate 65 | end 66 | end 67 | 68 | def define_store_with_attributes(**options) 69 | typed_store :settings, **options do |t| 70 | define_columns(t) 71 | t.any :author 72 | t.any :source, blank: false, default: 'web' 73 | t.any :signup, default: {} 74 | t.string :country, blank: false, default: 'Canada', accessor: false 75 | end 76 | end 77 | 78 | def define_stores_with_prefix_and_suffix(**options) 79 | typed_store(:prefixed_settings, prefix: true, **options) { |t| t.any :language } 80 | typed_store(:suffixed_settings, suffix: true, **options) { |t| t.any :language } 81 | typed_store(:custom_prefixed_settings, prefix: :custom, **options) { |t| t.any :language } 82 | typed_store(:custom_suffixed_settings, suffix: :custom, **options) { |t| t.any :language } 83 | end 84 | 85 | MigrationClass = ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"] 86 | class CreateAllTables < MigrationClass 87 | def self.up 88 | ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations.configs_for(env_name: "test", name: :test_sqlite3)) 89 | create_table(:sqlite3_regular_ar_models, force: true) { |t| define_columns(t); t.text :untyped_settings } 90 | create_table(:yaml_typed_store_models, force: true) { |t| %i[settings explicit_settings partial_settings untyped_settings prefixed_settings suffixed_settings custom_prefixed_settings custom_suffixed_settings].each { |column| t.text column}; t.string :regular_column } 91 | create_table(:json_typed_store_models, force: true) { |t| %i[settings explicit_settings partial_settings untyped_settings prefixed_settings suffixed_settings custom_prefixed_settings custom_suffixed_settings].each { |column| t.text column}; t.string :regular_column } 92 | create_table(:marshal_typed_store_models, force: true) { |t| %i[settings explicit_settings partial_settings untyped_settings prefixed_settings suffixed_settings custom_prefixed_settings custom_suffixed_settings].each { |column| t.text column}; t.string :regular_column } 93 | 94 | create_table(:dirty_tracking_models, force: true) do |t| 95 | t.string :title 96 | t.text :settings 97 | 98 | t.timestamps 99 | end 100 | end 101 | end 102 | ActiveRecord::Migration.verbose = true 103 | ActiveRecord::Migration.suppress_messages do 104 | CreateAllTables.up 105 | end 106 | 107 | class ColumnCoder 108 | 109 | def initialize(coder) 110 | @coder = coder 111 | end 112 | 113 | def load(data) 114 | return {} if data.blank? 115 | @coder.load(data) 116 | end 117 | 118 | def dump(data) 119 | @coder.dump(data || {}) 120 | end 121 | 122 | end 123 | 124 | class Sqlite3RegularARModel < ActiveRecord::Base 125 | establish_connection :test_sqlite3 126 | store :untyped_settings, accessors: [:title] 127 | end 128 | 129 | class YamlTypedStoreModel < ActiveRecord::Base 130 | establish_connection :test_sqlite3 131 | store :untyped_settings, accessors: [:title] 132 | 133 | after_update :read_active 134 | def read_active 135 | enabled 136 | end 137 | 138 | define_store_with_attributes 139 | define_store_with_no_attributes 140 | define_store_with_partial_attributes 141 | define_stores_with_prefix_and_suffix 142 | end 143 | 144 | class InheritedTypedStoreModel < YamlTypedStoreModel 145 | establish_connection :test_sqlite3 146 | 147 | typed_store :settings do |t| 148 | t.string :new_attribute 149 | end 150 | end 151 | 152 | class JsonTypedStoreModel < ActiveRecord::Base 153 | establish_connection :test_sqlite3 154 | store :untyped_settings, accessors: [:title] 155 | 156 | define_store_with_attributes(coder: ColumnCoder.new(JSON)) 157 | define_store_with_no_attributes(coder: ColumnCoder.new(JSON)) 158 | define_store_with_partial_attributes(coder: ColumnCoder.new(JSON)) 159 | define_stores_with_prefix_and_suffix(coder: ColumnCoder.new(JSON)) 160 | end 161 | 162 | module MarshalCoder 163 | extend self 164 | 165 | def load(serial) 166 | return unless serial.present? 167 | Marshal.load(Base64.decode64(serial)) 168 | end 169 | 170 | def dump(value) 171 | Base64.encode64(Marshal.dump(value)) 172 | end 173 | end 174 | 175 | class MarshalTypedStoreModel < ActiveRecord::Base 176 | establish_connection :test_sqlite3 177 | store :untyped_settings, accessors: [:title] 178 | 179 | define_store_with_attributes(coder: ColumnCoder.new(MarshalCoder)) 180 | define_store_with_no_attributes(coder: ColumnCoder.new(MarshalCoder)) 181 | define_store_with_partial_attributes(coder: ColumnCoder.new(MarshalCoder)) 182 | define_stores_with_prefix_and_suffix(coder: ColumnCoder.new(MarshalCoder)) 183 | end 184 | 185 | Models = [ 186 | Sqlite3RegularARModel, 187 | YamlTypedStoreModel, 188 | InheritedTypedStoreModel, 189 | JsonTypedStoreModel, 190 | MarshalTypedStoreModel 191 | ] 192 | 193 | class DirtyTrackingModel < ActiveRecord::Base 194 | after_update :read_active, if: -> { has_attribute?(:settings) } 195 | 196 | typed_store(:settings) do |f| 197 | f.boolean :active, default: false, null: false 198 | end 199 | 200 | def read_active 201 | active 202 | end 203 | end 204 | --------------------------------------------------------------------------------