├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── lint.yml │ ├── release.yml │ └── rspec.yml ├── .gitignore ├── .rspec ├── .rubocop-md.yml ├── .rubocop.yml ├── .rubocop └── rubocop_rspec.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bench ├── bench.rb └── setup.rb ├── bin ├── console └── setup ├── gemfiles ├── rails6.gemfile ├── rails7.gemfile ├── rails70.gemfile ├── rails8.gemfile ├── railsmaster.gemfile └── rubocop.gemfile ├── lib ├── store_attribute.rb └── store_attribute │ ├── active_record.rb │ ├── active_record │ ├── mutation_tracker.rb │ ├── store.rb │ └── type │ │ └── typed_store.rb │ └── version.rb ├── spec ├── cases │ ├── active_model_spec.rb │ ├── sti_spec.rb │ └── store_attribute_spec.rb ├── spec_helper.rb ├── store_attribute │ └── typed_store_spec.rb └── support │ ├── money_type.rb │ ├── page.rb │ ├── user.rb │ └── virtual_record.rb └── store_attribute.gemspec /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: palkan 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ### Tell us about your environment 7 | 8 | **Ruby Version:** 9 | 10 | **Rails Version:** 11 | 12 | **PostgreSQL Version:** 13 | 14 | **Store Attribute Version:** 15 | 16 | ### What did you do? 17 | 18 | ### What did you expect to happen? 19 | 20 | ### What actually happened? 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 16 | 17 | ### What is the purpose of this pull request? 18 | 19 | ### What changes did you make? (overview) 20 | 21 | ### Is there anything you'd like reviewers to focus on? 22 | 23 | ### Checklist 24 | 25 | - [ ] I've added tests for this change 26 | - [ ] I've added a Changelog entry 27 | - [ ] I've updated a documentation (Readme) 28 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Ruby 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | rubocop: 11 | runs-on: ubuntu-latest 12 | env: 13 | BUNDLE_JOBS: 4 14 | BUNDLE_RETRY: 3 15 | BUNDLE_GEMFILE: "gemfiles/rubocop.gemfile" 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: 3.2 21 | bundler-cache: true 22 | - name: Lint Ruby code with RuboCop 23 | run: | 24 | bundle exec rubocop 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release gems 2 | on: 3 | workflow_dispatch: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | id-token: write 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 # Fetch current tag as annotated. See https://github.com/actions/checkout/issues/290 19 | - uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: 3.2 22 | - name: Configure RubyGems Credentials 23 | uses: rubygems/configure-rubygems-credentials@main 24 | - name: Publish to RubyGems 25 | run: | 26 | gem install gem-release 27 | gem release 28 | -------------------------------------------------------------------------------- /.github/workflows/rspec.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | rspec: 11 | runs-on: ubuntu-latest 12 | env: 13 | BUNDLE_JOBS: 4 14 | BUNDLE_RETRY: 3 15 | BUNDLE_GEMFILE: "${{ matrix.gemfile }}" 16 | CI: true 17 | RAILS_ENV: test 18 | DATABASE_URL: postgres://postgres:postgres@localhost:5432 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | ruby: ["3.3"] 23 | postgres: ["17"] 24 | gemfile: [ 25 | "gemfiles/rails8.gemfile" 26 | ] 27 | include: 28 | - ruby: "3.0" 29 | postgres: "15" 30 | gemfile: "gemfiles/rails6.gemfile" 31 | - ruby: "3.2" 32 | postgres: "15" 33 | gemfile: "gemfiles/rails7.gemfile" 34 | - ruby: "3.3" 35 | postgres: "15" 36 | gemfile: "gemfiles/railsmaster.gemfile" 37 | - ruby: "3.1" 38 | postgres: "14" 39 | gemfile: "gemfiles/rails70.gemfile" 40 | services: 41 | postgres: 42 | image: postgres:${{ matrix.postgres }} 43 | ports: ["5432:5432"] 44 | env: 45 | POSTGRES_PASSWORD: postgres 46 | options: >- 47 | --health-cmd pg_isready 48 | --health-interval 10s 49 | --health-timeout 5s 50 | --health-retries 5 51 | steps: 52 | - uses: actions/checkout@v2 53 | - name: Install system deps 54 | run: | 55 | sudo apt-get update 56 | sudo apt-get -yqq install libpq-dev 57 | - uses: ruby/setup-ruby@v1 58 | with: 59 | ruby-version: ${{ matrix.ruby }} 60 | bundler-cache: true 61 | - name: Create DB 62 | run: | 63 | env PGPASSWORD=postgres createdb -h localhost -U postgres store_attribute_test 64 | - name: Run RSpec 65 | run: | 66 | bundle exec rspec -f d --force-color 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Numerous always-ignore extensions 2 | *.diff 3 | *.err 4 | *.orig 5 | *.log 6 | *.rej 7 | *.swo 8 | *.swp 9 | *.vi 10 | *~ 11 | *.sass-cache 12 | *.iml 13 | .idea/ 14 | 15 | # Sublime 16 | *.sublime-project 17 | *.sublime-workspace 18 | 19 | # OS or Editor folders 20 | .DS_Store 21 | .cache 22 | .project 23 | .settings 24 | .tmproj 25 | Thumbs.db 26 | coverage/ 27 | 28 | .bundle/ 29 | *.log 30 | *.gem 31 | pkg/ 32 | spec/dummy/log/*.log 33 | spec/dummy/tmp/ 34 | spec/dummy/.sass-cache 35 | Gemfile.local* 36 | Gemfile.lock 37 | tmp/ 38 | *.override.yml 39 | *.local.yml 40 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color -------------------------------------------------------------------------------- /.rubocop-md.yml: -------------------------------------------------------------------------------- 1 | inherit_from: ".rubocop.yml" 2 | 3 | require: 4 | - rubocop-md 5 | 6 | AllCops: 7 | Include: 8 | - '**/*.md' 9 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - standard/cop/block_single_line_braces 3 | 4 | inherit_gem: 5 | standard: config/base.yml 6 | 7 | inherit_from: 8 | - .rubocop/rubocop_rspec.yml 9 | 10 | AllCops: 11 | Exclude: 12 | - 'bin/*' 13 | - 'tmp/**/*' 14 | - 'vendor/**/*' 15 | - 'gemfiles/**/*' 16 | - 'bench/**/*' 17 | DisplayCopNames: true 18 | SuggestExtensions: false 19 | NewCops: disable 20 | TargetRubyVersion: 3.0 21 | 22 | Standard/BlockSingleLineBraces: 23 | Enabled: false 24 | 25 | Style/FrozenStringLiteralComment: 26 | Enabled: true 27 | -------------------------------------------------------------------------------- /.rubocop/rubocop_rspec.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rspec 3 | 4 | RSpec: 5 | Enabled: false 6 | 7 | RSpec/Focus: 8 | Enabled: true 9 | 10 | RSpec/EmptyExampleGroup: 11 | Enabled: true 12 | 13 | RSpec/EmptyLineAfterExampleGroup: 14 | Enabled: true 15 | 16 | RSpec/EmptyLineAfterFinalLet: 17 | Enabled: true 18 | 19 | RSpec/EmptyLineAfterHook: 20 | Enabled: true 21 | 22 | RSpec/EmptyLineAfterSubject: 23 | Enabled: true 24 | 25 | RSpec/HookArgument: 26 | Enabled: true 27 | 28 | RSpec/HooksBeforeExamples: 29 | Enabled: true 30 | 31 | RSpec/ImplicitExpect: 32 | Enabled: true 33 | 34 | RSpec/IteratedExpectation: 35 | Enabled: true 36 | 37 | RSpec/LetBeforeExamples: 38 | Enabled: true 39 | 40 | RSpec/MissingExampleGroupArgument: 41 | Enabled: true 42 | 43 | RSpec/ReceiveCounts: 44 | Enabled: true 45 | 46 | RSpec/ExcessiveDocstringSpacing: # new in 2.5 47 | Enabled: true 48 | 49 | RSpec/IdenticalEqualityAssertion: # new in 2.4 50 | Enabled: true 51 | 52 | RSpec/SubjectDeclaration: # new in 2.5 53 | Enabled: true 54 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## master 4 | 5 | ## 2.0.1 (2025-05-09) 🎇 6 | 7 | - Register store_attributes as attributes. ([@rickcsong](https://github.com/rickcsong)) 8 | 9 | ```ruby 10 | class User < ActiveRecord::Base 11 | self.store_attribute_register_attributes = true 12 | 13 | store_attribute :extra, :color, :string, default: "grey" 14 | end 15 | 16 | User.attribute_types.keys.include?("color") #=> true 17 | ``` 18 | 19 | ## 2.0.0 (2024-12-12) 20 | 21 | - **Breaking:** The `store_attribute_unset_values_fallback_to_default` option is now true by default, meaning that the default value will be returned when the attribute key is not present in the serialized value. 22 | 23 | For v1.x behavior, set the option to `false` globally as follows: 24 | 25 | ```ruby 26 | StoreAttribute.store_attribute_unset_values_fallback_to_default = false 27 | ``` 28 | 29 | - Ruby >= 3.0 is required. 30 | 31 | ## 1.3.1 (2024-09-19) 32 | 33 | - Populate missing defaults on user input when `store_attribute_unset_values_fallback_to_default` is true. ([@palkan][]) 34 | 35 | ## 1.3.0 (2024-09-03) 🗓️ 36 | 37 | - Fix using defaults when store attributes are inherited from a parent model. ([@palkan][]) 38 | 39 | - Allow specifying only default values w/o types. ([@palkan][]) 40 | 41 | ```ruby 42 | store_attribute :store, :tags, default: [] 43 | ``` 44 | 45 | - **Ruby >= 2.7 and Rails >= 6.1 are required**. ([@palkan][]) 46 | 47 | ## 1.2.0 (2023-11-29) 48 | 49 | - Support Rails >7.1. ([@palkan][]) 50 | 51 | - Fix handling of store attributes for not-yet-defined columns. ([@palkan][]) 52 | 53 | ## 1.1.1 (2023-06-27) 54 | 55 | - Lookup store attribute types only after schema load. 56 | 57 | ## 1.1.0 (2023-03-08) 🌷 58 | 59 | - Add configuration option to return default values when attribute key is not present in the serialized value ([@markedmondson][], [@palkan][]). 60 | 61 | Add to the class (preferrable `ApplicationRecord` or some other base class): 62 | 63 | ```ruby 64 | class ApplicationRecord < ActiveRecord::Base 65 | self.store_attribute_unset_values_fallback_to_default = true 66 | 67 | store_attribute :extra, :color, :string, default: "grey" 68 | end 69 | 70 | user = User.create!(extra: {}) 71 | # without the fallback 72 | user.color #=> nil 73 | # with fallback 74 | user.color #=> "grey" 75 | ``` 76 | 77 | ## 1.0.2 (2022-07-29) 78 | 79 | - Fix possible conflicts with Active Model objects. ([@palkan][]) 80 | 81 | - Fix passing suffix/prefix to `store_accessor` without types. ([@palkan][]) 82 | 83 | ## 1.0.1 (2022-05-05) 84 | 85 | - Fixed suffix/prefix for predicates. ([@Alan-Marx](https://github.com/Alan-Marx)) 86 | 87 | ## 1.0.0 (2022-03-17) 88 | 89 | - **Ruby 2.6+ and Rails 6+** is required. 90 | 91 | - Refactored internal implementation to use Rails Store implementation as much as possible. ([@palkan][]) 92 | 93 | Use existing Attributes API and Store API instead of duplicating and monkey-patching. Dirty-tracking, accessors and prefixes/suffixes are now handled by Rails. We only provide type coercions for stores. 94 | 95 | ## 0.9.3 (2021-11-17) 96 | 97 | - Fix keeping empty store hashes in the changes. ([@markedmondson][]) 98 | 99 | See [PR#22](https://github.com/palkan/store_attribute/pull/22). 100 | 101 | ## 0.9.2 (2021-10-13) 102 | 103 | - Fix bug with store mutation during changes calculation. ([@palkan][]) 104 | 105 | ## 0.9.1 106 | 107 | - Fix bug with dirty nullable stores. ([@palkan][]) 108 | 109 | ## 0.9.0 (2021-08-17) 📉 110 | 111 | - Default values no longer marked as dirty. ([@markedmondson][]) 112 | 113 | ## 0.8.1 (2020-12-03) 114 | 115 | - Fix adding dirty tracking methods for `store_attribute`. ([@palkan][]) 116 | 117 | ## 0.8.0 118 | 119 | - Add Rails 6.1 compatibility. ([@palkan][]) 120 | 121 | - Add support for `prefix` and `suffix` options. ([@palkan][]) 122 | 123 | ## 0.7.1 124 | 125 | - Fixed bug with `store` called without accessors. ([@ioki-klaus][]) 126 | 127 | See [#10](https://github.com/palkan/store_attribute/pull/10). 128 | 129 | ## 0.7.0 (2020-03-23) 130 | 131 | - Added dirty tracking methods. ([@glaszig][]) 132 | 133 | [PR #8](https://github.com/palkan/store_attribute/pull/8). 134 | 135 | ## 0.6.0 (2019-07-24) 136 | 137 | - Added default values support. ([@dreikanter][], [@SumLare][]) 138 | 139 | See [PR #7](https://github.com/palkan/store_attribute/pull/7). 140 | 141 | - Start keeping changelog. ([@palkan][]) 142 | 143 | [@palkan]: https://github.com/palkan 144 | [@dreikanter]: https://github.com/dreikanter 145 | [@SumLare]: https://github.com/SumLare 146 | [@glaszig]: https://github.com/glaszig 147 | [@ioki-klaus]: https://github.com/ioki-klaus 148 | [@markedmondson]: https://github.com/markedmondson 149 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "debug", platform: :mri 8 | 9 | eval_gemfile "gemfiles/rubocop.gemfile" 10 | 11 | local_gemfile = ENV.fetch("LOCAL_GEMFILE", "Gemfile.local") 12 | 13 | if File.exist?(local_gemfile) 14 | eval(File.read(local_gemfile)) # rubocop:disable Security/Eval 15 | else 16 | gem "activerecord", "~> 7.0" 17 | end 18 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright 2016-2023 palkan 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Cult Of Martians](http://cultofmartians.com/assets/badges/badge.svg)](https://cultofmartians.com/tasks/store-attribute-defaults.html#task) 2 | [![Gem Version](https://badge.fury.io/rb/store_attribute.svg)](https://rubygems.org/gems/store_attribute) 3 | ![Build](https://github.com/palkan/store_attribute/workflows/Build/badge.svg) 4 | 5 | ## Store Attribute 6 | 7 | ActiveRecord extension which adds typecasting to store accessors. 8 | 9 | Originally extracted from not merged PR to Rails: [rails/rails#18942](https://github.com/rails/rails/pull/18942). 10 | 11 | ### Install 12 | 13 | In your Gemfile: 14 | 15 | ```ruby 16 | # for Rails 6.1+ (7 is supported) 17 | gem "store_attribute", "~> 1.0" 18 | 19 | # for Rails 5+ (6 is supported) 20 | gem "store_attribute", "~> 0.8.0" 21 | 22 | # for Rails 4.2 23 | gem "store_attribute", "~> 0.4.0" 24 | ``` 25 | 26 | ### Usage 27 | 28 | You can use `store_attribute` method to add additional accessors with a type to an existing store on a model. 29 | 30 | ```ruby 31 | store_attribute(store_name, name, type, options) 32 | ``` 33 | 34 | Where: 35 | 36 | - `store_name` The name of the store. 37 | - `name` The name of the accessor to the store. 38 | - `type` A symbol such as `:string` or `:integer`, or a type object to be used for the accessor. 39 | - `options` (optional) A hash of cast type options such as `precision`, `limit`, `scale`, `default`. Regular `store_accessor` options, such as `prefix`, `suffix` are also supported. 40 | 41 | Type casting occurs every time you write data through accessor or update store itself 42 | and when object is loaded from database. 43 | 44 | Note that if you update store explicitly then value isn't type casted. 45 | 46 | Examples: 47 | 48 | ```ruby 49 | class MegaUser < User 50 | store_attribute :settings, :ratio, :integer, limit: 1 51 | store_attribute :settings, :login_at, :datetime 52 | store_attribute :settings, :active, :boolean 53 | store_attribute :settings, :color, :string, default: "red" 54 | store_attribute :settings, :colors, :json, default: ["red", "blue"] 55 | store_attribute :settings, :data, :datetime, default: -> { Time.now } 56 | end 57 | 58 | u = MegaUser.new(active: false, login_at: "2015-01-01 00:01", ratio: "63.4608") 59 | 60 | u.login_at.is_a?(DateTime) # => true 61 | u.login_at = DateTime.new(2015, 1, 1, 11, 0, 0) 62 | u.ratio # => 63 63 | u.active # => false 64 | # Default value is set 65 | u.color # => red 66 | # Default array is set 67 | u.colors # => ["red", "blue"] 68 | # A dynamic default can also be provided 69 | u.data # => Current time 70 | # And we also have a predicate method 71 | u.active? # => false 72 | u.reload 73 | 74 | # After loading record from db store contains casted data 75 | u.settings["login_at"] == DateTime.new(2015, 1, 1, 11, 0, 0) # => true 76 | 77 | # If you update store explicitly then the value returned 78 | # by accessor isn't type casted 79 | u.settings["ratio"] = "3.141592653" 80 | u.ratio # => "3.141592653" 81 | 82 | # On the other hand, writing through accessor set correct data within store 83 | u.ratio = "3.141592653" 84 | u.ratio # => 3 85 | u.settings["ratio"] # => 3 86 | ``` 87 | 88 | You can also specify type using usual `store_accessor` method: 89 | 90 | ```ruby 91 | class SuperUser < User 92 | store_accessor :settings, :privileges, login_at: :datetime 93 | end 94 | ``` 95 | 96 | Or through `store`: 97 | 98 | ```ruby 99 | class User < ActiveRecord::Base 100 | store :settings, accessors: [:color, :homepage, login_at: :datetime], coder: JSON 101 | end 102 | ``` 103 | 104 | ### Using defaults 105 | 106 | With `store_attribute`, you can provide default values for the store attribute. This functionality follows Rails behaviour for `attribute ..., default: ...` (and is backed by Attribute API). 107 | 108 | You must remember two things when using defaults: 109 | 110 | - A default value is only populated if no value for the **store** attribute was set, i.e., only when creating a new record. 111 | - Default values persist as soon as you save the record. 112 | 113 | The examples below demonstrate these caveats: 114 | 115 | ```ruby 116 | # Database schema 117 | create_table("users") do |t| 118 | t.string :name 119 | t.jsonb :extra 120 | end 121 | 122 | class RawUser < ActiveRecord::Base 123 | self.table_name = "users" 124 | end 125 | 126 | class User < ActiveRecord::Base 127 | attribute :name, :string, default: "Joe" 128 | store_attribute :extra, :expired_at, :date, default: -> { 2.days.from_now } 129 | end 130 | 131 | Date.current #=> 2022-03-17 132 | 133 | user = User.new 134 | user.name #=> "Joe" 135 | user.expired_at #=> 2022-03-19 136 | user.save! 137 | 138 | raw_user = RawUser.find(user.id) 139 | raw_user.name #=> "Joe" 140 | raw_user.expired_at #=> 2022-03-19 141 | 142 | another_raw_user = RawUser.create! 143 | another_user = User.find(another_raw_user.id) 144 | 145 | another_user.name #=> nil 146 | another_user.expired_at #=> nil 147 | ``` 148 | 149 | By default, Store Attribute returns the default value even when the record is persisted but the attribute name is not present: 150 | 151 | ```ruby 152 | user = User.create!(extra: {}) 153 | user.expired_at #=> 2022-03-19 154 | ``` 155 | 156 | You can disable this behaviour by setting the `store_attribute_unset_values_fallback_to_default` class option to `false` in your model: 157 | 158 | ```ruby 159 | class User < ApplicationRecord 160 | self.store_attribute_unset_values_fallback_to_default = false 161 | end 162 | 163 | user = User.create!(extra: {}) 164 | user.expired_at #=> nil 165 | ``` 166 | 167 | You can also configure the global default for this option in an initializer or application configuration: 168 | 169 | ```ruby 170 | # config/initializers/store_attribute.rb 171 | # # or 172 | # config/application.rb 173 | StoreAttribute.store_attribute_unset_values_fallback_to_default = false 174 | ``` 175 | 176 | ## Contributing 177 | 178 | Bug reports and pull requests are welcome on GitHub at [https://github.com/palkan/store_attribute](https://github.com/palkan/store_attribute). 179 | 180 | For local development, you'll need PostgreSQL up and running. You can either install it on your host machine or run via Docker as follows: 181 | 182 | ```bash 183 | docker run --name store_attribute_postgres -e POSTGRES_HOST_AUTH_METHOD=trust -e POSTGRES_USER=$USER -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d postgres 184 | docker exec -it store_attribute_postgres createdb -U $USER store_attribute_test 185 | ``` 186 | 187 | ## License 188 | 189 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 190 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | begin 9 | require "rubocop/rake_task" 10 | RuboCop::RakeTask.new 11 | 12 | RuboCop::RakeTask.new("rubocop:md") do |task| 13 | task.options << %w[-c .rubocop-md.yml] 14 | end 15 | rescue LoadError 16 | task(:rubocop) {} 17 | task("rubocop:md") {} 18 | end 19 | 20 | task default: %w[rubocop rubocop:md spec] 21 | -------------------------------------------------------------------------------- /bench/bench.rb: -------------------------------------------------------------------------------- 1 | require 'benchmark/ips' 2 | require './setup' 3 | 4 | Benchmark.ips do |x| 5 | x.report('SA initialize') do 6 | User.new(public: '1', published_at: '2016-01-01', age: '23') 7 | end 8 | 9 | x.report('AR-T initialize') do 10 | Looser.new(public: '1', published_at: '2016-01-01', age: '23') 11 | end 12 | end 13 | 14 | Benchmark.ips do |x| 15 | x.report('SA accessors') do 16 | u = User.new 17 | u.public = '1' 18 | u.published_at = '2016-01-01' 19 | u.age = '23' 20 | end 21 | 22 | x.report('AR-T accessors') do 23 | u = Looser.new 24 | u.public = '1' 25 | u.published_at = '2016-01-01' 26 | u.age = '23' 27 | end 28 | end 29 | 30 | Benchmark.ips do |x| 31 | x.report('SA create') do 32 | User.create!(public: '1', published_at: '2016-01-01', age: '23') 33 | end 34 | 35 | x.report('AR-T create') do 36 | Looser.create(public: '1', published_at: '2016-01-01', age: '23') 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /bench/setup.rb: -------------------------------------------------------------------------------- 1 | begin 2 | require 'bundler/inline' 3 | rescue LoadError => e 4 | $stderr.puts 'Bundler version 1.10 or later is required. Please update your Bundler' 5 | raise e 6 | end 7 | 8 | gemfile(true) do 9 | source 'https://rubygems.org' 10 | gem 'activerecord', '~>4.2' 11 | gem 'pg' 12 | gem 'activerecord-typedstore', require: false 13 | gem 'pry-byebug' 14 | gem 'benchmark-ips' 15 | gem 'memory_profiler' 16 | end 17 | 18 | DB_NAME = ENV['DB_NAME'] || 'sa_bench' 19 | 20 | begin 21 | system("createdb #{DB_NAME}") 22 | rescue 23 | $stdout.puts "DB already exists" 24 | end 25 | 26 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 27 | 28 | require 'active_record' 29 | require 'logger' 30 | require 'store_attribute' 31 | require 'activerecord-typedstore' 32 | 33 | ActiveRecord::Base.establish_connection(adapter: 'postgresql', database: DB_NAME) 34 | 35 | at_exit do 36 | ActiveRecord::Base.connection.disconnect! 37 | end 38 | 39 | module Bench 40 | module_function 41 | def setup_db 42 | ActiveRecord::Schema.define do 43 | create_table :users, force: true do |t| 44 | t.jsonb :data 45 | end 46 | 47 | create_table :loosers, force: true do |t| 48 | t.jsonb :data 49 | end 50 | end 51 | end 52 | end 53 | 54 | class User < ActiveRecord::Base 55 | store_accessor :data, public: :boolean, published_at: :datetime, age: :integer 56 | end 57 | 58 | class Looser < ActiveRecord::Base 59 | typed_store :data, coder: JSON do |s| 60 | s.boolean :public 61 | s.datetime :published_at 62 | s.integer :age 63 | end 64 | end 65 | 66 | # Run migration only if neccessary 67 | Bench.setup_db if ENV['FORCE'].present? || !ActiveRecord::Base.connection.tables.include?('users') 68 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "store_attribute" 5 | 6 | require "pry" 7 | Pry.start 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | gem install bundler --conservative 6 | bundle check || bundle install 7 | 8 | createdb store_attribute_test 9 | -------------------------------------------------------------------------------- /gemfiles/rails6.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rails', '~> 6.1' 4 | gem 'concurrent-ruby', '1.3.4' 5 | gem 'psych', '< 4' 6 | 7 | gemspec path: '..' 8 | -------------------------------------------------------------------------------- /gemfiles/rails7.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rails', '~> 7.0' 4 | 5 | gemspec path: '..' 6 | -------------------------------------------------------------------------------- /gemfiles/rails70.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rails', '~> 7.0.0' 4 | 5 | gemspec path: '..' 6 | -------------------------------------------------------------------------------- /gemfiles/rails8.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rails', '~> 8.0' 4 | 5 | gemspec path: '..' 6 | -------------------------------------------------------------------------------- /gemfiles/railsmaster.gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rails', github: 'rails/rails' 4 | 5 | gemspec path: '..' 6 | -------------------------------------------------------------------------------- /gemfiles/rubocop.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" do 2 | gem "rubocop-md", "~> 1.0" 3 | gem "rubocop-rspec" 4 | gem "standard", "~> 1.0" 5 | end 6 | -------------------------------------------------------------------------------- /lib/store_attribute.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "store_attribute/version" 4 | require "store_attribute/active_record" 5 | 6 | module StoreAttribute 7 | class << self 8 | # Global default value for `store_attribute_unset_values_fallback_to_default` option. 9 | # Must be set before any model is loaded 10 | attr_accessor :store_attribute_unset_values_fallback_to_default 11 | attr_accessor :store_attribute_register_attributes 12 | end 13 | 14 | self.store_attribute_unset_values_fallback_to_default = true 15 | self.store_attribute_register_attributes = false 16 | end 17 | -------------------------------------------------------------------------------- /lib/store_attribute/active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "store_attribute/active_record/store" 4 | -------------------------------------------------------------------------------- /lib/store_attribute/active_record/mutation_tracker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StoreAttribute 4 | # Upgrade mutation tracker to return partial changes for typed stores 5 | module MutationTracker 6 | def change_to_attribute(attr_name) 7 | return super unless attributes.is_a?(ActiveModel::AttributeSet) 8 | return super unless attributes[attr_name].type.is_a?(ActiveRecord::Type::TypedStore) 9 | 10 | orig_changes = super 11 | 12 | return unless orig_changes 13 | 14 | prev_store, new_store = orig_changes.map(&:dup) 15 | 16 | prev_store&.each do |key, value| 17 | if new_store&.dig(key) == value 18 | prev_store.except!(key) 19 | new_store&.except!(key) 20 | end 21 | end 22 | 23 | [prev_store, new_store] 24 | end 25 | end 26 | end 27 | 28 | require "active_model/attribute_mutation_tracker" 29 | ActiveModel::AttributeMutationTracker.prepend(StoreAttribute::MutationTracker) 30 | -------------------------------------------------------------------------------- /lib/store_attribute/active_record/store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record/store" 4 | require "store_attribute/active_record/type/typed_store" 5 | require "store_attribute/active_record/mutation_tracker" 6 | 7 | module ActiveRecord 8 | module Store 9 | module ClassMethods # :nodoc: 10 | alias_method :_orig_store_without_types, :store 11 | alias_method :_orig_store_accessor_without_types, :store_accessor 12 | 13 | attr_writer :store_attribute_register_attributes 14 | attr_writer :store_attribute_unset_values_fallback_to_default 15 | 16 | # Defines store on this model. 17 | # 18 | # +store_name+ The name of the store. 19 | # 20 | # ==== Options 21 | # The following options are accepted: 22 | # 23 | # +coder+ The coder of the store. 24 | # 25 | # +accessors+ An array of the accessors to the store. 26 | # 27 | # Examples: 28 | # 29 | # class User < ActiveRecord::Base 30 | # store :settings, accessors: [:color, :homepage, login_at: :datetime], coder: JSON 31 | # end 32 | def store(store_name, options = {}) 33 | accessors = options.delete(:accessors) 34 | accessor_related_options = options.slice(:prefix, :suffix) 35 | typed_accessors = 36 | if accessors && accessors.last.is_a?(Hash) 37 | accessors.pop 38 | else 39 | {} 40 | end 41 | 42 | _orig_store_without_types(store_name, options) 43 | store_accessor(store_name, *accessors, **accessor_related_options, **typed_accessors) if accessors 44 | end 45 | 46 | # Adds additional accessors to an existing store on this model. 47 | # 48 | # +store_name+ The name of the store. 49 | # 50 | # +keys+ The array of the accessors to the store. 51 | # 52 | # +typed_keys+ The key-to-type hash of the accesors with type to the store. 53 | # 54 | # +prefix+ Accessor method name prefix 55 | # 56 | # +suffix+ Accessor method name suffix 57 | # 58 | # Examples: 59 | # 60 | # class SuperUser < User 61 | # store_accessor :settings, :privileges, login_at: :datetime 62 | # end 63 | def store_accessor(store_name, *keys, prefix: nil, suffix: nil, **typed_keys) 64 | keys = keys.flatten 65 | typed_keys = typed_keys.except(keys) 66 | 67 | _orig_store_accessor_without_types(store_name, *(keys - typed_keys.keys), prefix: prefix, suffix: suffix) 68 | 69 | typed_keys.each do |key, type| 70 | store_attribute(store_name, key, type, prefix: prefix, suffix: suffix) 71 | end 72 | end 73 | 74 | # Adds additional accessors with a type to an existing store on this model. 75 | # Type casting occurs every time you write data through accessor or update store itself 76 | # and when object is loaded from database. 77 | # 78 | # Note that if you update store explicitly then value isn't type casted. 79 | # 80 | # +store_name+ The name of the store. 81 | # 82 | # +name+ The name of the accessor to the store. 83 | # 84 | # +type+ A symbol such as +:string+ or +:integer+, or a type object 85 | # to be used for the accessor. 86 | # 87 | # +prefix+ Accessor method name prefix 88 | # 89 | # +suffix+ Accessor method name suffix 90 | # 91 | # +options+ A hash of cast type options such as +precision+, +limit+, +scale+. 92 | # 93 | # Examples: 94 | # 95 | # class MegaUser < User 96 | # store_attribute :settings, :ratio, :integer, limit: 1 97 | # store_attribute :settings, :login_at, :datetime 98 | # 99 | # store_attribute :extra, :version, :integer, prefix: :meta 100 | # end 101 | # 102 | # u = MegaUser.new(active: false, login_at: '2015-01-01 00:01', ratio: "63.4608", meta_version: "1") 103 | # 104 | # u.login_at.is_a?(DateTime) # => true 105 | # u.login_at = DateTime.new(2015,1,1,11,0,0) 106 | # u.ratio # => 63 107 | # u.meta_version #=> 1 108 | # u.reload 109 | # 110 | # # After loading record from db store contains casted data 111 | # u.settings['login_at'] == DateTime.new(2015,1,1,11,0,0) # => true 112 | # 113 | # # If you update store explicitly then the value returned 114 | # # by accessor isn't type casted 115 | # u.settings['ration'] = "3.141592653" 116 | # u.ratio # => "3.141592653" 117 | # 118 | # # On the other hand, writing through accessor set correct data within store 119 | # u.ratio = "3.141592653" 120 | # u.ratio # => 3 121 | # u.settings['ratio'] # => 3 122 | # 123 | # For more examples on using types, see documentation for ActiveRecord::Attributes. 124 | def store_attribute(store_name, name, type = :value, prefix: nil, suffix: nil, **options) 125 | _orig_store_accessor_without_types(store_name, name.to_s, prefix: prefix, suffix: suffix) 126 | _define_predicate_method(name, prefix: prefix, suffix: suffix) if type == :boolean 127 | 128 | _define_store_attribute(store_name) if !_local_typed_stored_attributes? || 129 | _local_typed_stored_attributes[store_name][:types].empty? || 130 | # Defaults owner has changed, we must decorate the attribute to correctly propagate the defaults 131 | ( 132 | options.key?(:default) && _local_typed_stored_attributes[store_name][:owner] != self 133 | ) 134 | 135 | _local_typed_stored_attributes[store_name][:owner] = self if options.key?(:default) || !_local_typed_stored_attributes? 136 | _local_typed_stored_attributes[store_name][:types][name] = [type, options] 137 | 138 | if store_attribute_register_attributes 139 | cast_type = 140 | if type == :value 141 | ActiveModel::Type::Value.new(**options.except(:default)) 142 | else 143 | ActiveRecord::Type.lookup(type, **options.except(:default)) 144 | end 145 | 146 | attribute(name, cast_type, **options) 147 | end 148 | end 149 | 150 | def store_attribute_register_attributes 151 | return @store_attribute_register_attributes if instance_variable_defined?(:@store_attribute_register_attributes) 152 | 153 | @store_attribute_register_attributes = 154 | if superclass.respond_to?(:store_attribute_register_attributes) 155 | superclass.store_attribute_register_attributes 156 | else 157 | StoreAttribute.store_attribute_register_attributes 158 | end 159 | end 160 | 161 | def store_attribute_unset_values_fallback_to_default 162 | return @store_attribute_unset_values_fallback_to_default if instance_variable_defined?(:@store_attribute_unset_values_fallback_to_default) 163 | 164 | @store_attribute_unset_values_fallback_to_default = 165 | if superclass.respond_to?(:store_attribute_unset_values_fallback_to_default) 166 | superclass.store_attribute_unset_values_fallback_to_default 167 | else 168 | StoreAttribute.store_attribute_unset_values_fallback_to_default 169 | end 170 | end 171 | 172 | def _local_typed_stored_attributes? 173 | instance_variable_defined?(:@local_typed_stored_attributes) 174 | end 175 | 176 | def _local_typed_stored_attributes 177 | return @local_typed_stored_attributes if _local_typed_stored_attributes? 178 | 179 | @local_typed_stored_attributes = 180 | if superclass.respond_to?(:_local_typed_stored_attributes) 181 | superclass._local_typed_stored_attributes.dup.tap do |h| 182 | h.transform_values! { |v| {owner: v[:owner], types: v[:types].dup} } 183 | end 184 | else 185 | Hash.new { |h, k| h[k] = {types: {}.with_indifferent_access} }.with_indifferent_access 186 | end 187 | end 188 | 189 | def _define_store_attribute(store_name) 190 | attr_name = store_name.to_s 191 | 192 | defaultik = Type::TypedStore::Defaultik.new 193 | 194 | owner = self 195 | 196 | # Rails >7.1 197 | if respond_to?(:decorate_attributes) 198 | decorate_attributes([attr_name]) do |_, subtype| 199 | subtypes = _local_typed_stored_attributes[attr_name][:types] 200 | type = Type::TypedStore.create_from_type(subtype) 201 | type.owner = owner 202 | defaultik.type = type 203 | subtypes.each do |name, (cast_type, options)| 204 | type.add_typed_key(name, cast_type, **options.symbolize_keys) 205 | end 206 | 207 | type 208 | end 209 | 210 | attribute(attr_name, default: defaultik.proc) 211 | # Rails >=6.1, <=7.1 212 | else 213 | was_type = attributes_to_define_after_schema_loads[attr_name]&.first 214 | 215 | attribute(attr_name, default: defaultik.proc) do |subtype| 216 | subtypes = _local_typed_stored_attributes[attr_name][:types] 217 | subtype = _lookup_cast_type(attr_name, was_type, {}) if defined?(_lookup_cast_type) 218 | 219 | type = Type::TypedStore.create_from_type(subtype) 220 | type.owner = owner 221 | defaultik.type = type 222 | subtypes.each do |name, (cast_type, options)| 223 | type.add_typed_key(name, cast_type, **options.symbolize_keys) 224 | end 225 | 226 | # Make sure default attribute uses the correct type, so #changed? works as expected 227 | # This is dirty hack that makes Rails <7.2 works similar to Rails >=7.2. Please, upgrade :) 228 | if type.defaults.any? && _default_attributes[attr_name] && !_default_attributes[attr_name].type.is_a?(Type::TypedStore) 229 | _default_attributes[attr_name] = 230 | ActiveModel::Attribute.from_database(attr_name, _default_attributes[attr_name].value.deep_dup, type) 231 | end 232 | 233 | type 234 | end 235 | end 236 | end 237 | 238 | def _define_predicate_method(name, prefix: nil, suffix: nil) 239 | accessor_prefix = 240 | case prefix 241 | when String, Symbol 242 | "#{prefix}_" 243 | when TrueClass 244 | "#{name}_" 245 | else 246 | "" 247 | end 248 | accessor_suffix = 249 | case suffix 250 | when String, Symbol 251 | "_#{suffix}" 252 | when TrueClass 253 | "_#{name}" 254 | else 255 | "" 256 | end 257 | 258 | _store_accessors_module.module_eval do 259 | name = "#{accessor_prefix}#{name}#{accessor_suffix}" 260 | 261 | define_method("#{name}?") do 262 | send(name) == true 263 | end 264 | end 265 | end 266 | end 267 | end 268 | end 269 | -------------------------------------------------------------------------------- /lib/store_attribute/active_record/type/typed_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record/type" 4 | 5 | module ActiveRecord 6 | module Type # :nodoc: 7 | class TypedStore < DelegateClass(ActiveRecord::Type::Value) # :nodoc: 8 | class Defaultik 9 | attr_accessor :type 10 | 11 | def proc 12 | @proc ||= Kernel.proc do 13 | raise ArgumentError, "Has no type attached" unless type 14 | 15 | type.build_defaults 16 | end 17 | end 18 | end 19 | 20 | # Creates +TypedStore+ type instance and specifies type caster 21 | # for key. 22 | def self.create_from_type(basetype, **options) 23 | return basetype.dup if basetype.is_a?(self) 24 | 25 | new(basetype) 26 | end 27 | 28 | attr_writer :owner 29 | attr_reader :defaults 30 | 31 | def initialize(subtype) 32 | @accessor_types = {} 33 | @defaults = {} 34 | @subtype = subtype 35 | super 36 | end 37 | 38 | UNDEFINED = Object.new 39 | 40 | def add_typed_key(key, type, default: UNDEFINED, **options) 41 | type = ActiveModel::Type::Value.new(**options) if type == :value 42 | type = ActiveRecord::Type.lookup(type, **options) if type.is_a?(Symbol) 43 | safe_key = key.to_s 44 | @accessor_types[safe_key] = type 45 | @defaults[safe_key] = default unless default == UNDEFINED 46 | end 47 | 48 | def deserialize(value) 49 | hash = super 50 | return hash unless hash 51 | accessor_types.each do |key, type| 52 | if hash.key?(key) 53 | hash[key] = type.deserialize(hash[key]) 54 | elsif fallback_to_default?(key) 55 | hash[key] = built_defaults[key] 56 | end 57 | end 58 | hash 59 | end 60 | 61 | def changed_in_place?(raw_old_value, new_value) 62 | deserialize(raw_old_value) != new_value 63 | end 64 | 65 | def serialize(value) 66 | return super unless value.is_a?(Hash) 67 | typed_casted = {} 68 | accessor_types.each do |str_key, type| 69 | key = key_to_cast(value, str_key) 70 | next unless key 71 | if value.key?(key) 72 | typed_casted[key] = type.serialize(value[key]) 73 | end 74 | end 75 | super(value.merge(typed_casted)) 76 | end 77 | 78 | def cast(value) 79 | hash = super 80 | return hash unless hash 81 | accessor_types.each do |key, type| 82 | if hash.key?(key) 83 | hash[key] = type.cast(hash[key]) 84 | elsif fallback_to_default?(key) 85 | hash[key] = built_defaults[key] 86 | end 87 | end 88 | hash 89 | end 90 | 91 | def accessor 92 | self 93 | end 94 | 95 | def mutable? 96 | true 97 | end 98 | 99 | def write(object, attribute, key, value) 100 | value = type_for(key).cast(value) if typed?(key) 101 | store_accessor.write(object, attribute, key, value) 102 | end 103 | 104 | delegate :get, :read, :prepare, to: :store_accessor 105 | 106 | def build_defaults 107 | defaults.transform_values do |val| 108 | val.is_a?(Proc) ? val.call : val 109 | end.with_indifferent_access 110 | end 111 | 112 | def dup 113 | self.class.new(__getobj__).tap do |dtype| 114 | dtype.accessor_types.merge!(accessor_types) 115 | dtype.defaults.merge!(defaults) 116 | end 117 | end 118 | 119 | protected 120 | 121 | def built_defaults 122 | @built_defaults ||= build_defaults 123 | end 124 | 125 | # We cannot rely on string keys 'cause user input can contain symbol keys 126 | def key_to_cast(val, key) 127 | return key if val.key?(key) 128 | return key.to_sym if val.key?(key.to_sym) 129 | key if defaults.key?(key) 130 | end 131 | 132 | def typed?(key) 133 | accessor_types.key?(key.to_s) 134 | end 135 | 136 | def type_for(key) 137 | accessor_types.fetch(key.to_s) 138 | end 139 | 140 | def fallback_to_default?(key) 141 | owner&.store_attribute_unset_values_fallback_to_default && defaults.key?(key) 142 | end 143 | 144 | def store_accessor 145 | subtype.accessor 146 | end 147 | 148 | attr_reader :accessor_types, :subtype, :owner 149 | end 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/store_attribute/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module StoreAttribute # :nodoc: 4 | VERSION = "2.0.1" 5 | end 6 | -------------------------------------------------------------------------------- /spec/cases/active_model_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | # Regression test for: https://github.com/palkan/store_attribute/issues/26 6 | describe ActiveModel do 7 | let(:record) { VirtualRecord.new } 8 | 9 | specify do 10 | record.content = nil 11 | record.content = "Zeit" 12 | 13 | expect(record.changes).to eq({"content" => [nil, "Zeit"]}) 14 | end 15 | 16 | context "with active model attributes" do 17 | let(:record) { AttributedVirtualRecord.new } 18 | 19 | specify do 20 | record.content = "Zeit" 21 | 22 | expect(record.changes).to eq({"content" => [nil, "Zeit"]}) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/cases/sti_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe "STI" do 6 | after do 7 | Page.delete_all 8 | end 9 | 10 | describe "defaults" do 11 | it "should inherit defaults" do 12 | page = MediaBannerPage.new 13 | expect(page.heading_level).to eq("2") 14 | expect(page.media_type).to eq("image") 15 | expect(page.media_placement).to eq("right") 16 | 17 | expect { page.save! }.to change(Page, :count).by(1) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/cases/store_attribute_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | using(Module.new do 6 | unless Time.now.respond_to?(:to_fs) 7 | refine Time do 8 | alias_method :to_fs, :to_s 9 | end 10 | end 11 | end) 12 | 13 | describe StoreAttribute do 14 | after do 15 | User.delete_all 16 | end 17 | 18 | let(:date) { Date.new(2019, 7, 17) } 19 | let(:default_date) { User::DEFAULT_DATE } 20 | let(:dynamic_date) { User::TODAY_DATE } 21 | let(:time) { DateTime.new(2015, 2, 14, 17, 0, 0) } 22 | let(:time_str) { "2015-02-14 17:00" } 23 | let(:time_str_utc) { "2015-02-14 17:00:00 UTC" } 24 | 25 | context "hstore" do 26 | it "typecasts on build" do 27 | user = User.new(visible: "t", login_at: time_str) 28 | expect(user.visible).to eq true 29 | expect(user).to be_visible 30 | expect(user.login_at).to eq time 31 | end 32 | 33 | it "typecasts on reload" do 34 | user = User.new(visible: "t", login_at: time_str) 35 | user.save! 36 | user = User.find(user.id) 37 | 38 | expect(user.visible).to eq true 39 | expect(user).to be_visible 40 | expect(user.login_at).to eq time 41 | end 42 | 43 | it "works with accessors" do 44 | user = User.new 45 | user.visible = false 46 | user.login_at = time_str 47 | user.save! 48 | 49 | user = User.find(user.id) 50 | 51 | expect(user.visible).to be false 52 | expect(user).not_to be_visible 53 | expect(user.login_at).to eq time 54 | 55 | ron = RawUser.find(user.id) 56 | expect(ron.hdata["visible"]).to eq "false" 57 | expect(ron.hdata["login_at"]).to eq time_str_utc 58 | end 59 | 60 | it "handles options" do 61 | expect { User.create!(ratio: 1024) }.to raise_error(RangeError) 62 | end 63 | 64 | it "YAML roundtrip" do 65 | user = User.create!(visible: "0", login_at: time_str) 66 | dumped = YAML.unsafe_load(YAML.dump(user)) 67 | 68 | expect(dumped.visible).to be false 69 | expect(dumped.login_at).to eq time 70 | end 71 | end 72 | 73 | context "jsonb" do 74 | it "typecasts on build" do 75 | jamie = User.new( 76 | active: "true", 77 | salary: 3.1999, 78 | birthday: "2000-01-01" 79 | ) 80 | expect(jamie).to be_active 81 | expect(jamie.salary).to eq 3 82 | expect(jamie.birthday).to eq Date.new(2000, 1, 1) 83 | expect(jamie.jparams["birthday"]).to eq Date.new(2000, 1, 1) 84 | expect(jamie.jparams["active"]).to eq true 85 | end 86 | 87 | it "typecasts on reload" do 88 | jamie = User.create!(jparams: {"active" => "1", "birthday" => "01/01/2000", "salary" => "3.14"}) 89 | jamie = User.find(jamie.id) 90 | 91 | expect(jamie).to be_active 92 | expect(jamie.salary).to eq 3 93 | expect(jamie.birthday).to eq Date.new(2000, 1, 1) 94 | expect(jamie.jparams["birthday"]).to eq Date.new(2000, 1, 1) 95 | expect(jamie.jparams["active"]).to eq true 96 | end 97 | 98 | it "works with accessors" do 99 | john = User.new 100 | john.active = 1 101 | 102 | expect(john).to be_active 103 | expect(john.jparams["active"]).to eq true 104 | 105 | john.jparams = {active: "true", salary: "123.123", birthday: "01/01/2012"} 106 | expect(john).to be_active 107 | expect(john.birthday).to eq Date.new(2012, 1, 1) 108 | expect(john.salary).to eq 123 109 | 110 | john.save! 111 | 112 | ron = RawUser.find(john.id) 113 | expect(ron.jparams["active"]).to eq true 114 | expect(ron.jparams["birthday"]).to eq "2012-01-01" 115 | expect(ron.jparams["salary"]).to eq 123 116 | end 117 | 118 | it "re-typecast old data" do 119 | jamie = User.create! 120 | User.update_all( 121 | "jparams = '{" \ 122 | '"active":"1",' \ 123 | '"salary":"12.02"' \ 124 | "}'::jsonb" 125 | ) 126 | 127 | jamie = User.find(jamie.id) 128 | expect(jamie).to be_active 129 | expect(jamie.salary).to eq 12 130 | 131 | jamie.salary = 13 132 | 133 | jamie.save! 134 | 135 | ron = RawUser.find(jamie.id) 136 | expect(ron.jparams["active"]).to eq true 137 | expect(ron.jparams["salary"]).to eq 13 138 | end 139 | 140 | it "typecasts on update" do 141 | jamie = User.new( 142 | active: false, 143 | salary: 3.1999, 144 | birthday: "2000-01-01" 145 | ) 146 | expect(jamie).not_to be_active 147 | expect(jamie.salary).to eq 3 148 | 149 | jamie.update!(active: "1", salary: "100") 150 | 151 | expect(jamie).to be_active 152 | expect(jamie.salary).to eq 100 153 | end 154 | end 155 | 156 | context "custom types" do 157 | it "typecasts on build" do 158 | user = User.new(price: "$1") 159 | expect(user.price).to eq 100 160 | end 161 | 162 | it "typecasts on reload" do 163 | jamie = User.create!(custom: {price: "$12"}) 164 | expect(jamie.reload.price).to eq 1200 165 | 166 | jamie = User.find(jamie.id) 167 | 168 | expect(jamie.price).to eq 1200 169 | end 170 | end 171 | 172 | context "store subtype" do 173 | it "typecasts on build" do 174 | user = User.new(inner_json: {x: 1}) 175 | expect(user.inner_json).to eq("x" => 1) 176 | end 177 | 178 | it "typecasts on update" do 179 | user = User.new 180 | user.update!(inner_json: {x: 1}) 181 | expect(user.inner_json).to eq("x" => 1) 182 | 183 | expect(user.reload.inner_json).to eq("x" => 1) 184 | end 185 | 186 | it "typecasts on reload" do 187 | jamie = User.create!(inner_json: {x: 1}) 188 | jamie = User.find(jamie.id) 189 | expect(jamie.inner_json).to eq("x" => 1) 190 | end 191 | end 192 | 193 | context "default option" do 194 | it "should init the field after an object is created" do 195 | jamie = User.new 196 | expect(jamie.static_date).to eq(default_date) 197 | end 198 | 199 | it "should not affect explicit initialization" do 200 | jamie = User.new(static_date: date) 201 | expect(jamie.static_date).to eq(date) 202 | end 203 | 204 | it "should not affect explicit nil initialization" do 205 | jamie = User.new(static_date: nil) 206 | expect(jamie.static_date).to be_nil 207 | end 208 | 209 | it "should handle a static value" do 210 | jamie = User.create! 211 | jamie = User.find(jamie.id) 212 | expect(jamie.static_date).to eq(default_date) 213 | end 214 | 215 | it "should handle a lambda" do 216 | jamie = User.create! 217 | jamie = User.find(jamie.id) 218 | expect(jamie.dynamic_date).to eq(dynamic_date) 219 | end 220 | 221 | it "should handle nil" do 222 | jamie = User.create! 223 | jamie = User.find(jamie.id) 224 | expect(jamie.empty_date).to be_nil 225 | end 226 | 227 | it "should not mark as dirty" do 228 | jamie = User.create! 229 | jamie.static_date 230 | expect(jamie.changes).to eq({}) 231 | end 232 | 233 | it "should only include changed accessors" do 234 | jamie = User.create! 235 | jamie.static_date 236 | jamie.visible = true 237 | jamie.active = true 238 | expect(jamie.changes).to eq({"hdata" => [{}, {"visible" => true}], "jparams" => [{}, {"active" => true}]}) 239 | end 240 | 241 | it "should return defaults for missing attributes" do 242 | jamie = User.create!(jparams: {}) 243 | expect(jamie.static_date).to eq(default_date) 244 | expect(jamie.dynamic_date).to eq(dynamic_date) 245 | end 246 | 247 | it "should not return defaults for missing attributes when configured", :aggregate_failures do 248 | klass = Class.new(User) do 249 | self.store_attribute_unset_values_fallback_to_default = false 250 | 251 | # We must redeclare at least a single attribute to associate it with the new class 252 | store_attribute :jparams, :static_date, :date, default: User::DEFAULT_DATE 253 | end 254 | 255 | subklass = Class.new(klass) do 256 | store_attribute :jparams, :non_default, :string, default: "no" 257 | end 258 | 259 | subsubklass = Class.new(subklass) do 260 | self.store_attribute_unset_values_fallback_to_default = true 261 | 262 | store_attribute :jparams, :non_default, :string, default: "no" 263 | end 264 | 265 | jamie = User.new(jparams: {}) 266 | jamie.save! 267 | 268 | jamie = User.find(jamie.id) 269 | expect(jamie.static_date).to eq(default_date) 270 | expect(jamie.dynamic_date).to eq(dynamic_date) 271 | 272 | subjamie = subklass.find(jamie.id) 273 | expect(subjamie.static_date).to be_nil 274 | expect(subjamie.dynamic_date).to be_nil 275 | expect(subjamie.non_default).to be_nil 276 | 277 | subsubjamie = subsubklass.find(jamie.id) 278 | expect(subsubjamie.static_date).to eq(default_date) 279 | expect(subsubjamie.dynamic_date).to eq(dynamic_date) 280 | expect(subsubjamie.non_default).to eq "no" 281 | end 282 | 283 | it "should support defaults without types" do 284 | jamie = User.create! 285 | expect(jamie.tags).to eq([]) 286 | 287 | jamie.tags << "rails" 288 | 289 | jamie.save! 290 | 291 | expect(jamie.reload.tags).to eq(["rails"]) 292 | end 293 | end 294 | 295 | context "prefix/suffix" do 296 | it "should accept prefix and suffix options for stores" do 297 | jamie = User.create!(json_active_value: "t", json_birthday_value: "2019-06-26") 298 | jamie = User.find(jamie.id) 299 | 300 | expect(jamie.json_active_value).to eql(true) 301 | expect(jamie.json_active_value?).to eql(true) 302 | expect(jamie.json_birthday_value).to eq(Time.local(2019, 6, 26).to_date) 303 | 304 | jamie.json_active_value = false 305 | 306 | expect(jamie.json_active_value_changed?).to eql(true) 307 | 308 | jamie.save! 309 | 310 | expect(jamie.saved_change_to_json_active_value).to eq([true, false]) 311 | end 312 | 313 | it "should accept prefix and suffix options when defining accessors through stores directly" do 314 | jamie = User.create! 315 | 316 | expect(jamie.details_age_years).to eq(nil) 317 | 318 | jamie.details_age_years = 30 319 | 320 | expect(jamie.details_age_years).to eql(30) 321 | 322 | jamie.save! 323 | 324 | expect(jamie.saved_change_to_details_age_years).to eq([nil, 30]) 325 | end 326 | 327 | it "should preserve prefix/suffix for store_accessor without types" do 328 | jamie = User.create!(jparams: {version: "x"}) 329 | 330 | expect(jamie.pre_version_suf).to eq("x") 331 | end 332 | end 333 | 334 | context "attributes" do 335 | it "should register all store_attributes as attributes" do 336 | expect(UserWithAttributes.attribute_types).to include( 337 | "active" => an_instance_of(ActiveModel::Type::Boolean), 338 | "salary" => an_instance_of(ActiveModel::Type::Integer), 339 | "birthday" => an_instance_of(ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Date), 340 | "inner_json" => an_instance_of(ActiveRecord::Type::Json), 341 | "price" => an_instance_of(MoneyType) 342 | ) 343 | end 344 | end 345 | 346 | context "dirty tracking" do 347 | let(:user) { User.create! } 348 | let(:now) { Time.now.utc } 349 | 350 | before do 351 | user.price = "$ 123" 352 | user.visible = false 353 | user.login_at = now.to_fs(:db) 354 | end 355 | 356 | it "should report changes" do 357 | expect(user.price_changed?).to be true 358 | expect(user.price_change).to eq [nil, 12300] 359 | expect(user.price_was).to eq nil 360 | 361 | expect(user.visible_changed?).to be true 362 | expect(user.visible_change).to eq [nil, false] 363 | expect(user.visible_was).to eq nil 364 | 365 | expect(user.login_at_changed?).to be true 366 | expect(user.login_at_change[0]).to be_nil 367 | expect(user.login_at_change[1].to_i).to eq now.to_i 368 | expect(user.login_at_was).to eq nil 369 | 370 | expect(user.changes["hdata"]).to eq [{}, {"login_at" => now.to_fs(:db), "visible" => false}] 371 | end 372 | 373 | it "should report saved changes" do 374 | user.save! 375 | 376 | expect(user.saved_change_to_price?).to be true 377 | expect(user.saved_change_to_price).to eq [nil, 12300] 378 | expect(user.price_before_last_save).to eq nil 379 | 380 | expect(user.saved_change_to_visible?).to be true 381 | expect(user.saved_change_to_visible).to eq [nil, false] 382 | expect(user.visible_before_last_save).to eq nil 383 | end 384 | 385 | it "should only report on changed accessors" do 386 | user.active = true 387 | expect(user.changes["jparams"]).to eq([{}, {"active" => true}]) 388 | expect(user.static_date_changed?).to be false 389 | end 390 | 391 | it "works with reload" do 392 | user.active = true 393 | expect(user.changes["jparams"]).to eq([{}, {"active" => true}]) 394 | user.save! 395 | 396 | user.reload 397 | user.static_date = Date.today + 2.days 398 | 399 | expect(user.static_date_changed?).to be true 400 | expect(user.active_changed?).to be false 401 | end 402 | 403 | it "should not modify stores" do 404 | user.price = 99.0 405 | expect(user.changes["custom"]).to eq([{}, {"price" => 99}]) 406 | user.save! 407 | 408 | user.reload 409 | user.custom_date = Date.today + 2.days 410 | 411 | expect(user.custom_date_changed?).to be true 412 | expect(user.price_changed?).to be false 413 | user.save! 414 | 415 | expect(user.reload.price).to be 99 416 | end 417 | 418 | it "should not include empty changes" do 419 | reloaded_user = User.take 420 | reloaded_user.inspect 421 | 422 | expect(reloaded_user.changes).to eq({}) 423 | expect(reloaded_user.changed_attributes).to eq({}) 424 | expect(reloaded_user.changed?).to be false 425 | end 426 | 427 | # https://github.com/palkan/store_attribute/issues/19 428 | it "without defaults" do 429 | user = UserWithoutDefaults.new 430 | user.birthday = "2019-06-26" 431 | 432 | expect(user.birthday_changed?).to eq true 433 | end 434 | 435 | it "with defaults for missing attributes" do 436 | jamie = User.create!(jparams: {}) 437 | 438 | expect(jamie.changes).to eq({}) 439 | end 440 | 441 | it "with defaults for missing attributes when configured" do 442 | klass = Class.new(User) do 443 | self.store_attribute_unset_values_fallback_to_default = true 444 | 445 | # re-assing type object 446 | store_accessor :jparams, :version, active: :boolean, salary: :integer 447 | end 448 | 449 | jamie = klass.create!(jparams: {}) 450 | 451 | expect(jamie.changes).to eq({}) 452 | end 453 | 454 | it "works if store attribute column is set to nil" do 455 | user.save! 456 | user.hdata = nil 457 | 458 | expect(user.visible_changed?).to be true 459 | expect(user.login_at_changed?).to be true 460 | end 461 | end 462 | 463 | context "original store implementation" do 464 | it "doesn't break original store when no accessors passed" do 465 | dummy_class = Class.new(ActiveRecord::Base) do 466 | self.table_name = "users" 467 | 468 | store :custom, coder: JSON 469 | end 470 | 471 | dummy = dummy_class.new(custom: {key: "text"}) 472 | 473 | expect(dummy.custom).to eq({"key" => "text"}) 474 | end 475 | end 476 | 477 | context "with concerns" do 478 | let(:concern) do 479 | # https://github.com/palkan/store_attribute/pull/24#issuecomment-999833947 480 | Module.new do 481 | extend ActiveSupport::Concern 482 | 483 | included do 484 | store_accessor :extra, beginning_of_week: :integer 485 | 486 | include(Module.new do 487 | def beginning_of_week 488 | super || 6 489 | end 490 | end) 491 | end 492 | end 493 | end 494 | 495 | let(:raw_klass) do 496 | Class.new(RawUser).tap do |kl| 497 | kl.include concern 498 | 499 | kl.store_attribute :jparams, :some_flag, :boolean, default: true 500 | end 501 | end 502 | 503 | let(:klass) do 504 | Class.new(User).tap do |kl| 505 | kl.store_attribute_unset_values_fallback_to_default = false 506 | 507 | kl.store_attribute :jparams, :some_flag, :boolean, default: true 508 | # Use this attribute to compare the behaviour of store vs regular attributes default values 509 | kl.attribute :name, :string, default: "john" 510 | 511 | kl.include concern 512 | end 513 | end 514 | 515 | specify do 516 | user = klass.new 517 | expect(user.beginning_of_week).to eq 6 518 | expect(user.some_flag).to eq(true) 519 | user.save! 520 | 521 | user = klass.find(user.id) 522 | expect(user).to have_attributes( 523 | beginning_of_week: 6, 524 | some_flag: true 525 | ) 526 | 527 | ruser = raw_klass.new(beginning_of_week: "4") 528 | 529 | expect(ruser.beginning_of_week).to eq 4 530 | expect(ruser.some_flag).to eq(true) 531 | ruser.save! 532 | 533 | ruser = raw_klass.find(ruser.id) 534 | expect(ruser).to have_attributes( 535 | beginning_of_week: 4, 536 | some_flag: true 537 | ) 538 | end 539 | 540 | specify "defaults persistence" do 541 | user = klass.new 542 | expect(user.some_flag).to eq(true) 543 | expect(user.name).to eq("john") 544 | user.save! 545 | 546 | user = RawUser.find(user.id) 547 | # Defaults persist 548 | expect(user).to have_attributes( 549 | name: "john", 550 | jparams: a_hash_including("some_flag" => true) 551 | ) 552 | end 553 | 554 | specify "defaults inheritance vs persistence" do 555 | user = User.create! 556 | expect(user.jparams).not_to be_empty 557 | 558 | user = klass.find(user.id) 559 | user.name 560 | 561 | expect(user).to have_attributes( 562 | name: nil, 563 | some_flag: nil 564 | ) 565 | end 566 | 567 | specify "defaults vs user input" do 568 | user = klass.create!(jparams: {some_flag: false}) 569 | user = RawUser.find(user.id) 570 | 571 | # When store is set explicitly by user, 572 | # no defaults are populated 573 | expect(user.jparams).to eq( 574 | "some_flag" => false 575 | ) 576 | 577 | user = klass.create!(some_flag: false) 578 | user = RawUser.find(user.id) 579 | 580 | expect(user.jparams.keys).to include("some_flag", "static_date", "dynamic_date") 581 | 582 | # But if fallback is enabled, it must populate defaults 583 | subklass = Class.new(klass) do 584 | self.store_attribute_unset_values_fallback_to_default = true 585 | 586 | # We must redeclare at least a single attribute to associate it with the new class 587 | store_attribute :jparams, :static_date, :date, default: User::DEFAULT_DATE 588 | end 589 | 590 | jamie = subklass.new(jparams: {}) 591 | expect(jamie.static_date).to eq(default_date) 592 | end 593 | 594 | specify "double encoding" do 595 | user = klass.create!(inner_json: %w[kis kis]) 596 | 597 | user = klass.find(user.id) 598 | expect(user).to have_attributes( 599 | inner_json: %w[kis kis] 600 | ) 601 | 602 | # Parent class should also be able to decode the attribute 603 | pre_user = User.find(user.id) 604 | expect(pre_user).to have_attributes( 605 | inner_json: %w[kis kis] 606 | ) 607 | end 608 | end 609 | 610 | context "with validations" do 611 | let(:dummy_class) do 612 | Class.new(ActiveRecord::Base) do 613 | self.table_name = "users" 614 | 615 | store_accessor :jparams, :required_key 616 | store_attribute :jparams, :required_key, :string, default: "value" 617 | 618 | validates :jparams, :required_key, presence: true 619 | end 620 | end 621 | 622 | it "defaults are considered in validations" do 623 | expect(dummy_class.new).to be_valid 624 | end 625 | end 626 | 627 | context "when column is not present in the table" do 628 | let(:dummy_class) do 629 | Class.new(ActiveRecord::Base) do 630 | self.table_name = "users" 631 | 632 | store_attribute :missing, :cat, :string 633 | end 634 | end 635 | 636 | specify do 637 | expect(dummy_class.new).to be_valid 638 | end 639 | end 640 | end 641 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "logger" 4 | require "debug" unless ENV["CI"] 5 | 6 | begin 7 | gem "psych", "< 4" 8 | require "psych" 9 | rescue Gem::LoadError 10 | end 11 | 12 | require "active_record" 13 | require "openssl" 14 | require "pg" 15 | require "store_attribute" 16 | 17 | connection_params = 18 | if ENV.key?("DATABASE_URL") 19 | {"url" => ENV["DATABASE_URL"]} 20 | else 21 | { 22 | "host" => ENV["DB_HOST"] || "localhost", 23 | "username" => ENV["DB_USER"] 24 | } 25 | end 26 | 27 | if ActiveRecord.respond_to?(:use_yaml_unsafe_load) 28 | ActiveRecord.use_yaml_unsafe_load = false 29 | ActiveRecord.yaml_column_permitted_classes << Date 30 | elsif ActiveRecord::Base.respond_to?(:yaml_column_permitted_classes) 31 | ActiveRecord::Base.yaml_column_permitted_classes << Date 32 | end 33 | 34 | ActiveRecord::Base.establish_connection( 35 | { 36 | "adapter" => "postgresql", 37 | "database" => "store_attribute_test" 38 | }.merge(connection_params) 39 | ) 40 | 41 | ActiveRecord::Base.logger = Logger.new($stdout) if ENV["LOG"] 42 | 43 | connection = ActiveRecord::Base.connection 44 | 45 | unless connection.extension_enabled?("hstore") 46 | connection.enable_extension "hstore" 47 | connection.commit_db_transaction 48 | end 49 | 50 | connection.reconnect! 51 | 52 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].sort.each { |f| require f } 53 | 54 | RSpec.configure do |config| 55 | config.mock_with :rspec 56 | 57 | config.filter_run_when_matching :focus 58 | 59 | config.example_status_persistence_file_path = "tmp/rspec_examples.txt" 60 | 61 | if config.files_to_run.one? 62 | config.default_formatter = "doc" 63 | end 64 | 65 | config.order = :random 66 | Kernel.srand config.seed 67 | end 68 | -------------------------------------------------------------------------------- /spec/store_attribute/typed_store_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | describe ActiveRecord::Type::TypedStore do 6 | if ActiveRecord::Coders::JSON.is_a?(Class) && ActiveRecord::Coders::JSON.public_method_defined?(:load) 7 | let(:coder) { ActiveRecord::Coders::JSON.new } 8 | else 9 | let(:coder) { ActiveRecord::Coders::JSON } 10 | end 11 | let(:json_type) { ActiveRecord::Type::Serialized.new(ActiveRecord::Type::Text.new, coder) } 12 | 13 | context "with json store" do 14 | subject { described_class.new(json_type) } 15 | 16 | describe "#cast" do 17 | it "without key types", :aggregate_failures do 18 | expect(subject.cast([1, 2])).to eq [1, 2] 19 | expect(subject.cast("a" => "b")).to eq("a" => "b") 20 | end 21 | 22 | it "with type keys" do 23 | subject.add_typed_key("date", :date) 24 | 25 | date = ::Date.new(2016, 6, 22) 26 | expect(subject.cast(date: "2016-06-22")).to eq("date" => date) 27 | end 28 | end 29 | 30 | describe "#deserialize" do 31 | it "without key types", :aggregate_failures do 32 | expect(subject.deserialize("[1,2]")).to eq [1, 2] 33 | expect(subject.deserialize('{"a":"b"}')).to eq("a" => "b") 34 | end 35 | 36 | it "with type keys" do 37 | subject.add_typed_key("date", :date) 38 | 39 | date = ::Date.new(2016, 6, 22) 40 | expect(subject.deserialize('{"date":"2016-06-22"}')).to eq("date" => date) 41 | end 42 | 43 | it "with no default" do 44 | subject.add_typed_key("val", :integer) 45 | 46 | expect(subject.deserialize("{}")).to eq({}) 47 | end 48 | 49 | it "with default" do 50 | subject.add_typed_key("val", :integer, default: 1) 51 | 52 | expect(subject.deserialize("{}")).to eq({}) 53 | end 54 | 55 | it "with owner configured to store_attribute_unset_values_fallback_to_default" do 56 | subject.owner = Struct.new(:store_attribute_unset_values_fallback_to_default).new(true) 57 | 58 | subject.add_typed_key("val", :integer, default: 1) 59 | 60 | expect(subject.deserialize("{}")).to eq("val" => 1) 61 | end 62 | end 63 | 64 | describe "#serialize" do 65 | it "without key types", :aggregate_failures do 66 | expect(subject.serialize([1, 2])).to eq "[1,2]" 67 | expect(subject.serialize("a" => "b")).to eq '{"a":"b"}' 68 | end 69 | 70 | it "with type keys" do 71 | subject.add_typed_key("date", :date) 72 | 73 | date = ::Date.new(2016, 6, 22) 74 | expect(subject.serialize(date: date)).to eq '{"date":"2016-06-22"}' 75 | end 76 | 77 | it "with type key with option" do 78 | subject.add_typed_key("val", :integer, limit: 1) 79 | 80 | expect { subject.serialize(val: 1024) }.to raise_error(RangeError) 81 | end 82 | end 83 | 84 | describe "Defaultik" do 85 | let(:type) { described_class.new(json_type) } 86 | 87 | subject do 88 | described_class::Defaultik.new.tap do |df| 89 | df.type = type 90 | end.then(&:proc) 91 | end 92 | 93 | specify do 94 | date = ::Date.new(2016, 6, 22) 95 | ddate = ::Date.new(2021, 11, 23) 96 | type.add_typed_key("date", :date, default: date) 97 | type.add_typed_key("another_date", :date, default: -> { ddate }) 98 | 99 | expect(subject.call).to eq("date" => date, "another_date" => ddate) 100 | end 101 | end 102 | 103 | describe ".create_from_type" do 104 | it "creates with valid types", :aggregate_failures do 105 | type = described_class.create_from_type(json_type) 106 | type.add_typed_key("date", :date) 107 | 108 | new_type = described_class.create_from_type(type) 109 | new_type.add_typed_key("val", :integer) 110 | 111 | date = ::Date.new(2016, 6, 22) 112 | 113 | expect(type.cast(date: "2016-06-22", val: "1.2")).to eq("date" => date, "val" => "1.2") 114 | expect(new_type.cast(date: "2016-06-22", val: "1.2")).to eq("date" => date, "val" => 1) 115 | end 116 | end 117 | end 118 | 119 | context "with yaml coder" do 120 | let(:yaml_type) do 121 | ActiveRecord::Type::Serialized.new( 122 | ActiveRecord::Type::Text.new, 123 | ActiveRecord::Store::IndifferentCoder.new( 124 | "test", 125 | ActiveRecord::Coders::YAMLColumn.new("test", Hash) 126 | ) 127 | ) 128 | end 129 | 130 | subject { described_class.new(yaml_type) } 131 | 132 | it "works", :aggregate_failures do 133 | subject.add_typed_key("date", :date) 134 | 135 | date = ::Date.new(2016, 6, 22) 136 | 137 | expect(subject.cast(date: "2016-06-22")).to eq("date" => date) 138 | expect(subject.cast("date" => "2016-06-22")).to eq("date" => date) 139 | expect(subject.deserialize("---\n:date: 2016-06-22\n")).to eq("date" => date) 140 | expect(subject.deserialize("---\ndate: 2016-06-22\n")).to eq("date" => date) 141 | 142 | # https://github.com/rails/rails/pull/45591 143 | if ::ActiveRecord::VERSION::STRING >= "6.1.0" 144 | expect(subject.serialize(date: date)).to eq "---\n:date: 2016-06-22\n" 145 | expect(subject.serialize("date" => date)).to eq "---\ndate: 2016-06-22\n" 146 | else 147 | expect(subject.serialize(date: date)).to eq "--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\ndate: 2016-06-22\n" 148 | expect(subject.serialize("date" => date)).to eq "--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\ndate: 2016-06-22\n" 149 | end 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /spec/support/money_type.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class MoneyType < ActiveRecord::Type::Integer 4 | def cast(value) 5 | if !value.is_a?(Numeric) && value&.include?("$") 6 | price_in_dollars = value.delete("$").to_f 7 | super(price_in_dollars * 100) 8 | else 9 | super 10 | end 11 | end 12 | end 13 | 14 | ActiveRecord::Type.register(:money_type, MoneyType) 15 | -------------------------------------------------------------------------------- /spec/support/page.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | connection = ActiveRecord::Base.connection 4 | 5 | connection.drop_table "pages", if_exists: true 6 | 7 | connection.transaction do 8 | connection.create_table("pages") do |t| 9 | t.string :title 10 | t.jsonb :content 11 | t.jsonb :design 12 | t.string :type 13 | end 14 | end 15 | 16 | class RawPage < ActiveRecord::Base 17 | self.table_name = "pages" 18 | end 19 | 20 | class Page < ActiveRecord::Base 21 | end 22 | 23 | class BannerPage < Page 24 | store_attribute :content, :media_placement, :string, default: "right" 25 | end 26 | 27 | class MediaBannerPage < BannerPage 28 | store_attribute :design, :heading_level, :string, default: "2" 29 | store_attribute :content, :media_type, :string, default: "image" 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | connection = ActiveRecord::Base.connection 4 | 5 | connection.drop_table "users", if_exists: true 6 | 7 | connection.transaction do 8 | connection.create_table("users") do |t| 9 | t.string :name 10 | t.jsonb :extra 11 | t.string :dyndate 12 | t.string :statdate 13 | t.jsonb :jparams, default: {}, null: false 14 | t.text :custom 15 | t.hstore :hdata, default: {}, null: false 16 | end 17 | end 18 | 19 | class RawUser < ActiveRecord::Base 20 | self.table_name = "users" 21 | end 22 | 23 | class UserWithoutDefaults < ActiveRecord::Base 24 | self.table_name = "users" 25 | 26 | store_attribute :extra, :birthday, :date 27 | end 28 | 29 | class UserWithAttributes < ActiveRecord::Base 30 | self.table_name = "users" 31 | self.store_attribute_register_attributes = true 32 | 33 | store_accessor :jparams, active: :boolean, birthday: :date, prefix: "json", suffix: "value" 34 | store_attribute :jparams, :inner_json, :json 35 | store_attribute :hdata, :salary, :integer 36 | store :custom, accessors: [:custom_date, price: :money_type] 37 | end 38 | 39 | class User < ActiveRecord::Base 40 | DEFAULT_DATE = ::Date.new(2019, 7, 17) 41 | TODAY_DATE = ::Date.today 42 | 43 | attribute :dyndate, :datetime, default: -> { ::Time.now } 44 | attribute :statdate, :datetime, default: ::Time.now 45 | 46 | store_accessor :jparams, :version, active: :boolean, salary: :integer 47 | store_accessor :jparams, :version, prefix: :pre, suffix: :suf 48 | store_attribute :jparams, :birthday, :date 49 | store_attribute :jparams, :static_date, :date, default: DEFAULT_DATE 50 | store_attribute :jparams, :dynamic_date, :date, default: -> { TODAY_DATE } 51 | store_attribute :jparams, :empty_date, :date, default: nil 52 | store_attribute :jparams, :inner_json, :json 53 | store_attribute :jparams, :tags, default: [] 54 | 55 | store_accessor :jparams, active: :boolean, birthday: :date, prefix: "json", suffix: "value" 56 | 57 | store :custom, accessors: [:custom_date, price: :money_type] 58 | after_initialize { self.custom_date = TODAY_DATE } 59 | 60 | store_accessor :hdata, visible: :boolean 61 | 62 | store_attribute :hdata, :ratio, :integer, limit: 1 63 | store_attribute :hdata, :login_at, :datetime 64 | 65 | store :details, accessors: [:age], prefix: true, suffix: :years 66 | end 67 | -------------------------------------------------------------------------------- /spec/support/virtual_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class VirtualRecord 4 | include ActiveModel::Model 5 | include ActiveModel::Validations 6 | include ActiveModel::Dirty 7 | 8 | define_attribute_methods :content 9 | 10 | attr_reader :content 11 | 12 | def content=(value) 13 | @content = value 14 | content_will_change! 15 | end 16 | 17 | def reset_dirty_tracking 18 | changes_applied 19 | end 20 | end 21 | 22 | class AttributedVirtualRecord 23 | include ActiveModel::Model 24 | include ActiveModel::Validations 25 | include ActiveModel::Dirty 26 | include ActiveModel::Attributes 27 | 28 | attribute :content 29 | end 30 | -------------------------------------------------------------------------------- /store_attribute.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/store_attribute/version" 4 | 5 | # Describe your gem and declare its dependencies: 6 | Gem::Specification.new do |s| 7 | s.name = "store_attribute" 8 | s.version = StoreAttribute::VERSION 9 | s.authors = ["palkan"] 10 | s.email = ["dementiev.vm@gmail.com"] 11 | s.homepage = "http://github.com/palkan/store_attribute" 12 | s.summary = "ActiveRecord extension which adds typecasting to store accessors" 13 | s.description = "ActiveRecord extension which adds typecasting to store accessors" 14 | s.license = "MIT" 15 | 16 | s.files = Dir.glob("lib/**/*") + %w[README.md LICENSE.txt CHANGELOG.md] 17 | s.require_paths = ["lib"] 18 | 19 | s.required_ruby_version = ">= 3.0.0" 20 | 21 | s.metadata = { 22 | "bug_tracker_uri" => "http://github.com/palkan/store_attribute/issues", 23 | "changelog_uri" => "https://github.com/palkan/store_attribute/blob/master/CHANGELOG.md", 24 | "documentation_uri" => "http://github.com/palkan/store_attribute", 25 | "homepage_uri" => "http://github.com/palkan/store_attribute", 26 | "source_code_uri" => "http://github.com/palkan/store_attribute" 27 | } 28 | 29 | s.add_runtime_dependency "activerecord", ">= 6.1" 30 | 31 | s.add_development_dependency "pg", ">= 1.0" 32 | s.add_development_dependency "rake", ">= 13.0" 33 | s.add_development_dependency "rspec", ">= 3.5.0" 34 | end 35 | --------------------------------------------------------------------------------