├── .git-blame-ignore-revs ├── .github ├── CODEOWNERS └── workflows │ ├── ci.yml │ ├── publish.yml │ ├── rails_main_testing.yml │ └── ruby-gem-publication.yml ├── .gitignore ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── benchmark └── read.rb ├── gemfiles ├── common.rb ├── rails6.1.gemfile ├── rails7.0.gemfile ├── rails7.1.gemfile ├── rails7.2.gemfile └── rails_main.gemfile ├── lib ├── property_sets.rb └── property_sets │ ├── action_view_extension.rb │ ├── active_record_extension.rb │ ├── casting.rb │ ├── delegator.rb │ ├── property_set_model.rb │ └── version.rb ├── property_sets.gemspec └── spec ├── casting_spec.rb ├── delegator_spec.rb ├── inheritance_spec.rb ├── property_sets_spec.rb ├── spec_helper.rb ├── support ├── acts_like_an_integer.rb ├── database.yml ├── database_config.rb ├── database_migrations.rb └── models.rb └── view_extensions_spec.rb /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | 0f442b1777212eb5f0574f1d66d8692d7b42c94d 2 | 9f9fbc7e8e711fd3c3f1377c415dffba14d08d48 3 | fa5717a68557d679b76f8a485466bd3192c06ad7 4 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @zendesk/database-gem-owners 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | workflow_dispatch: 9 | 10 | jobs: 11 | specs: 12 | runs-on: ubuntu-latest 13 | 14 | name: Ruby ${{ matrix.ruby-version }}, ${{ matrix.gemfile }}, LCH ${{ matrix.legacy_connection_handling }} 15 | 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | ruby-version: 20 | - '3.1' 21 | - '3.2' 22 | - '3.3' 23 | - '3.4' 24 | gemfile: 25 | - rails6.1 26 | - rails7.0 27 | - rails7.1 28 | - rails7.2 29 | legacy_connection_handling: 30 | - 'true' 31 | - 'false' 32 | include: 33 | - {ruby-version: '3.3', gemfile: rails_main, legacy_connection_handling: 'false'} 34 | env: 35 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 36 | LEGACY_CONNECTION_HANDLING: ${{ matrix.legacy_connection_handling }} 37 | steps: 38 | - uses: actions/checkout@v4 39 | - name: Set up Ruby 40 | uses: ruby/setup-ruby@v1 41 | with: 42 | ruby-version: ${{ matrix.ruby-version }} 43 | bundler-cache: true 44 | - name: RSpec 45 | run: bundle exec rspec 46 | 47 | specs_successful: 48 | name: Specs passing? 49 | needs: specs 50 | if: always() 51 | runs-on: ubuntu-latest 52 | steps: 53 | - run: | 54 | if ${{ needs.specs.result == 'success' }} 55 | then 56 | echo "All specs pass" 57 | else 58 | echo "Some specs failed" 59 | false 60 | fi 61 | 62 | lint: 63 | runs-on: ubuntu-latest 64 | steps: 65 | - uses: actions/checkout@v4 66 | - name: Set up Ruby 67 | uses: ruby/setup-ruby@v1 68 | with: 69 | ruby-version: "3.1" 70 | bundler-cache: true 71 | - run: bundle exec rake standard 72 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to RubyGems.org 2 | 3 | on: 4 | push: 5 | branches: main 6 | paths: lib/property_sets/version.rb 7 | workflow_dispatch: 8 | 9 | jobs: 10 | publish: 11 | runs-on: ubuntu-latest 12 | environment: rubygems-publish 13 | if: github.repository_owner == 'zendesk' 14 | permissions: 15 | id-token: write 16 | contents: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | bundler-cache: false 23 | ruby-version: "3.4" 24 | - name: Install dependencies 25 | run: bundle install 26 | - uses: rubygems/release-gem@v1 27 | -------------------------------------------------------------------------------- /.github/workflows/rails_main_testing.yml: -------------------------------------------------------------------------------- 1 | name: Test against Rails main 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" # Run every day at 00:00 UTC 6 | workflow_dispatch: 7 | 8 | jobs: 9 | specs: 10 | runs-on: ubuntu-latest 11 | 12 | name: Ruby ${{ matrix.ruby-version }}, ${{ matrix.gemfile }} 13 | 14 | strategy: 15 | fail-fast: false 16 | matrix: 17 | ruby-version: 18 | - '3.4' 19 | gemfile: 20 | - rails_main 21 | env: 22 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 23 | steps: 24 | - uses: zendesk/checkout@v4 25 | - name: Set up Ruby 26 | uses: zendesk/setup-ruby@v1 27 | with: 28 | ruby-version: ${{ matrix.ruby-version }} 29 | bundler-cache: true 30 | - name: RSpec 31 | run: bundle exec rspec 32 | -------------------------------------------------------------------------------- /.github/workflows/ruby-gem-publication.yml: -------------------------------------------------------------------------------- 1 | name: Ruby Gem Publish 2 | 3 | on: 4 | push: 5 | tags: v* 6 | 7 | jobs: 8 | call-workflow: 9 | uses: zendesk/gw/.github/workflows/ruby-gem-publication.yml@main 10 | secrets: 11 | RUBY_GEMS_API_KEY: ${{ secrets.RUBY_GEMS_API_KEY }} 12 | RUBY_GEMS_TOTP_DEVICE: ${{ secrets.RUBY_GEMS_TOTP_DEVICE }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | gemfiles/*.lock 3 | test/*.log 4 | .rvmrc 5 | coverage 6 | rdoc 7 | doc 8 | .yardoc 9 | .bundle 10 | pkg 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [3.13.0] - 2024-07-08 11 | 12 | * Removed upper limit on Rails, added testing for Rails main. 13 | * Dropped support for Ruby < 3.1. 14 | * Dropped support for Rails < 6.1. 15 | 16 | ## [3.12.0] - 2023-12-12 17 | 18 | * Added support for Rails 7.1 19 | 20 | ## [3.11.0] - 2023-11-08 21 | 22 | * Property tables can now live on a separate database to their parent models. This is achieved, on a per-model basis, by configuring the connection class that will be used by property sets. e.g. set `self.property_sets_connection_class = Foo` on the model to instruct `property_sets` to use `Foo`'s database connection when looking for the property sets tables. 23 | 24 | ## [3.10.0] - 2023-09-18 25 | 26 | * Property models now inherit from the same parent as their owners (this unblocks [using multiple databases natively in Rails](https://guides.rubyonrails.org/active_record_multiple_databases.html)). 27 | * Dropped support for Rails 5. 28 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | eval_gemfile "gemfiles/rails6.1.gemfile" 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Property sets [![Build Status](https://github.com/zendesk/property_sets/workflows/CI/badge.svg)](https://github.com/zendesk/property_sets/actions?query=workflow%3ACI) 2 | 3 | This gem is a way for you to use a basic "key/value" store for storing attributes for a given model in a relational fashion where there's a row per attribute. Alternatively you'd need to add a new column per attribute to your main table, or serialize the attributes and their values using the [Active Record Store](https://api.rubyonrails.org/classes/ActiveRecord/Store.html). 4 | 5 | ## Description 6 | 7 | You configure the allowed stored properties by specifying these in the model: 8 | 9 | ```ruby 10 | class Account < ActiveRecord::Base 11 | property_set :settings do 12 | property :version, :default => "v1.0" 13 | property :featured, :protected => true 14 | property :activated 15 | end 16 | 17 | property_set :texts do 18 | property :epilogue 19 | end 20 | end 21 | ``` 22 | 23 | The declared properties can then be accessed runtime via the defined association: 24 | 25 | ```ruby 26 | # Return the value of the version record for this account, or the default value if not set 27 | account.settings.version 28 | 29 | # Update the version record with given value 30 | account.settings.version = "v1.1" 31 | 32 | # Query the truth value of the property 33 | account.settings.featured? 34 | 35 | # Short hand for setting one or more values 36 | account.settings.set(:version => "v1.2", :activated => true) 37 | 38 | # Short hand for getting a hash with pairs for each key argument 39 | account.settings.get([:version, :activated]) 40 | ``` 41 | 42 | You can also forward read, write and query methods to the properties with `PropertySets::Delegator`. 43 | 44 | ```ruby 45 | class Account < ActiveRecord::Base 46 | include PropertySets::Delegator 47 | delegate_to_property_set :settings, :is_open => :open, :same => :same 48 | end 49 | 50 | account.open #=> account.settings.is_open 51 | ``` 52 | 53 | These classes and their subclasses will inherit specified properties. 54 | 55 | ### Validations 56 | 57 | Property sets supports standard AR validations, although in a somewhat manual fashion. 58 | 59 | ```ruby 60 | class Account < ActiveRecord::Base 61 | property_set :settings do 62 | property :version, :default => "v1.0" 63 | property :featured, :protected => true 64 | 65 | validates_format_of :value, :with => /v\d+\.\d+/, :message => "of version is invalid", 66 | :if => Proc.new { |r| r.name.to_sym == :version } 67 | end 68 | end 69 | ``` 70 | 71 | On `account.save` this will result in an error record being added. You can also inspect the 72 | setting record using `account.settings.version_record` 73 | 74 | ### Bulk operations 75 | 76 | Stored properties can also be updated with the update_attributes and update_attributes! methods by 77 | enabling nested attributes. Like this (from the test cases): 78 | 79 | ```ruby 80 | @account.texts_attributes = [ 81 | { :name => "foo", :value => "1" }, 82 | { :name => "bar", :value => "0" } 83 | ] 84 | ``` 85 | 86 | And for existing records: 87 | 88 | ```ruby 89 | @account.update_attributes!(:texts_attributes => [ 90 | { :id => @account.texts.foo.id, :name => "foo", :value => "0" }, 91 | { :id => @account.texts.bar.id, :name => "bar", :value => "1" } 92 | ]) 93 | ``` 94 | 95 | Using nested attributes is subject to implementing your own security measures for mass update assignments. 96 | Alternatively, it is possible to use a custom hash structure: 97 | 98 | ```ruby 99 | params = { 100 | :settings => { :version => "v4.0", :featured => "1" }, 101 | :texts => { :epilogue => "Wibble wobble" } 102 | } 103 | 104 | @account.update_attributes(params) 105 | ``` 106 | 107 | The above will not update `featured` as this has the protected flag set and is hence protected from 108 | mass updates. 109 | 110 | ### View helpers 111 | 112 | We support a couple of convenience mechanisms for building forms and putting the values into the above hash structure. So far, only support check boxes and radio buttons: 113 | 114 | ```erb 115 | <% form_for(:account, :html => { :method => :put }) do |f| %> 116 |

<%= f.property_set(:settings).check_box :activated %> Activated?

117 |

<%= f.property_set(:settings).radio_button :hot, "yes" %> Hot

118 |

<%= f.property_set(:settings).radio_button :not, "no" %> Not

119 |

<%= f.property_set(:settings).select :level, [["One", 1], ["Two", 2]] %>

120 | <% end %> 121 | ``` 122 | 123 | ## Installation 124 | 125 | Install the gem in your rails project by putting it in your Gemfile: 126 | 127 | ``` 128 | gem "property_sets" 129 | ``` 130 | 131 | Also remember to create the storage table(s), if for example you are going to be using this with an accounts model and a "settings" property set, you can define the table like: 132 | 133 | ```ruby 134 | create_table :account_settings do |t| 135 | t.integer :account_id, :null => false 136 | t.string :name, :null => false 137 | t.string :value 138 | t.timestamps 139 | end 140 | 141 | add_index :account_settings, [ :account_id, :name ], :unique => true 142 | ``` 143 | 144 | If you would like to serialize larger objects into your property sets, you can use a `TEXT` column type for value like this: 145 | 146 | ```ruby 147 | create_table :account_settings do |t| 148 | t.integer :account_id, :null => false 149 | t.string :name, :null => false 150 | t.text :value 151 | t.timestamps 152 | end 153 | 154 | add_index :account_settings, [ :account_id, :name ], :unique => true 155 | ``` 156 | 157 | ### Releasing a new version 158 | A new version is published to RubyGems.org every time a change to `version.rb` is pushed to the `main` branch. 159 | In short, follow these steps: 160 | 1. Update `version.rb`, 161 | 2. update version in all `Gemfile.lock` files, 162 | 3. merge this change into `main`, and 163 | 4. look at [the action](https://github.com/zendesk/property_sets/actions/workflows/publish.yml) for output. 164 | 165 | To create a pre-release from a non-main branch: 166 | 1. change the version in `version.rb` to something like `1.2.0.pre.1` or `2.0.0.beta.2`, 167 | 2. push this change to your branch, 168 | 3. go to [Actions → “Publish to RubyGems.org” on GitHub](https://github.com/zendesk/property_sets/actions/workflows/publish.yml), 169 | 4. click the “Run workflow” button, 170 | 5. pick your branch from a dropdown. 171 | 172 | ### Storage table(s) on separate databases 173 | 174 | By default, `property_sets` looks for the storage table(s) on the same database as the model. If you need the storage tables to live on a different database you can configure a custom connection class on a per-model basis: 175 | 176 | ``` ruby 177 | class MainConnectionClass < ActiveRecord::Base 178 | self.abstract_class = true 179 | 180 | connects_to(database: { writing: foo }) 181 | end 182 | 183 | class SeparateDatabase < ActiveRecord::Base 184 | self.abstract_class = true 185 | 186 | connects_to(database: { writing: bar }) 187 | end 188 | 189 | class Account < MainConnectionClass 190 | # Ensure you set this _before_ configuring the property sets. 191 | self.property_sets_connection_class = SeparateDatabase 192 | 193 | property_set :settings do 194 | property :foo 195 | end 196 | end 197 | ``` 198 | 199 | In the above example, the `Accounts` table would live on the `foo` database and the storage table(s) will be written to the `bar` database. 200 | 201 | ## Requirements 202 | 203 | * ActiveRecord 204 | * ActiveSupport 205 | 206 | ## License and copyright 207 | 208 | Copyright 2013 Zendesk 209 | 210 | Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. 211 | You may obtain a copy of the License at 212 | 213 | http://www.apache.org/licenses/LICENSE-2.0 214 | 215 | Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. 216 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "bundler/gem_tasks" 3 | require "bump/tasks" 4 | require "rspec/core/rake_task" 5 | require "standard/rake" 6 | 7 | task default: [:spec, :standard] 8 | 9 | RSpec::Core::RakeTask.new(:spec) 10 | -------------------------------------------------------------------------------- /benchmark/read.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path(File.dirname(__FILE__) + "/../test/helper") 2 | 3 | class Account < ActiveRecord::Base 4 | # Benchmark reading from an object with many settings when: 5 | # 1. Settings are undefined and use the default value (empty) 6 | # 2. Settings are defined and use the database value (fully defined) 7 | 8 | # This is a fairly realastic example of an object that has a lot of settings 9 | property_set :benchmark_settings do 10 | # 30 simple objects 11 | 10.times do |i| 12 | property "float_prop_#{i}", type: :float, default: 3.1415 13 | property "int_prop_#{i}", type: :integer, default: 22 14 | property "string_prop_#{i}", type: :string, default: "Sausalito, CA" 15 | end 16 | 17 | # 10 complex 18 | 5.times do |i| 19 | property "datetime_prop_#{i}", type: :datetime, default: Time.now.to_s 20 | property "serialized_prop_#{i}", type: :serialized, default: {"Hello" => "There"} 21 | end 22 | 23 | # 60 booleans 24 | 60.times do |i| 25 | property "boolean_prop_#{i}", type: :boolean, default: true 26 | end 27 | end 28 | end 29 | 30 | class BenchmarkRead < ActiveSupport::TestCase 31 | context "property sets" do 32 | setup do 33 | @account = Account.create(name: "Name") 34 | end 35 | 36 | should "benchmark fully defined settings" do 37 | # Most settings are defined and will come from the database 38 | @account.benchmark_settings.keys.each do |key| 39 | @account.benchmark_settings.build_default(key) 40 | end 41 | @account.save! 42 | @account.reload 43 | assert_equal 100, @account.benchmark_settings.count 44 | 45 | GC.start 46 | full_timing = Benchmark.ms do 47 | 1_000.times do 48 | read_settings(@account) 49 | end 50 | end 51 | puts "Reading fully defined settings: #{full_timing}ms" 52 | end 53 | 54 | should "benchmark defaults" do 55 | assert_equal 0, @account.benchmark_settings.count 56 | # Most settings are undefined and will use the default value 57 | GC.start 58 | empty_timing = Benchmark.ms do 59 | 1_000.times do 60 | read_settings(@account) 61 | end 62 | end 63 | puts "Reading empty settings: #{empty_timing}ms" 64 | end 65 | end 66 | 67 | def read_settings(account) 68 | account.benchmark_settings.float_prop_1 69 | account.benchmark_settings.int_prop_1 70 | account.benchmark_settings.string_prop_1 71 | account.benchmark_settings.datetime_prop_1 72 | account.benchmark_settings.boolean_prop_20? 73 | account.benchmark_settings.boolean_prop_30? 74 | account.benchmark_settings.boolean_prop_40? 75 | account.benchmark_settings.boolean_prop_50? 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /gemfiles/common.rb: -------------------------------------------------------------------------------- 1 | gem "pry-byebug", require: false 2 | -------------------------------------------------------------------------------- /gemfiles/rails6.1.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: "../" 4 | 5 | gem "activerecord", "~> 6.1.0" 6 | gem "actionpack", "~> 6.1.0" 7 | gem "sqlite3", "~> 1.4" 8 | 9 | eval_gemfile "common.rb" 10 | gem "base64" 11 | gem "bigdecimal" 12 | gem "mutex_m" 13 | -------------------------------------------------------------------------------- /gemfiles/rails7.0.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: "../" 4 | 5 | gem "activerecord", "~> 7.0.0" 6 | gem "actionpack", "~> 7.0.0" 7 | gem "sqlite3", "~> 1.4" 8 | 9 | eval_gemfile "common.rb" 10 | gem "base64" 11 | gem "bigdecimal" 12 | gem "mutex_m" 13 | -------------------------------------------------------------------------------- /gemfiles/rails7.1.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: "../" 4 | 5 | gem "activerecord", "~> 7.1.0" 6 | gem "actionpack", "~> 7.1.0" 7 | gem "sqlite3", "~> 1.4" 8 | 9 | eval_gemfile "common.rb" 10 | -------------------------------------------------------------------------------- /gemfiles/rails7.2.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: "../" 4 | 5 | gem "activerecord", "~> 7.2.0" 6 | gem "actionpack", "~> 7.2.0" 7 | gem "sqlite3", "~> 1.4" 8 | 9 | eval_gemfile "common.rb" 10 | -------------------------------------------------------------------------------- /gemfiles/rails_main.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec path: "../" 4 | 5 | gem "activerecord", github: "rails/rails", branch: "main" 6 | gem "actionpack", github: "rails/rails", branch: "main" 7 | gem "sqlite3", "~> 2" 8 | 9 | eval_gemfile "common.rb" 10 | -------------------------------------------------------------------------------- /lib/property_sets.rb: -------------------------------------------------------------------------------- 1 | require "property_sets/property_set_model" 2 | require "property_sets/active_record_extension" 3 | require "property_sets/version" 4 | 5 | begin 6 | require "property_sets/action_view_extension" 7 | rescue LoadError 8 | end 9 | 10 | if "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}" == "6.1" 11 | ActiveRecord::Base.singleton_class.alias_method :connection_class_for_self, :connection_classes 12 | end 13 | 14 | module PropertySets 15 | def self.ensure_property_set_class(association, owner_class_name) 16 | const_name = "#{owner_class_name.demodulize}#{association.to_s.singularize.camelcase}" 17 | namespace = owner_class_name.deconstantize.safe_constantize || Object 18 | 19 | unless namespace.const_defined?(const_name, false) 20 | property_class = Class.new(parent_for_property_class(namespace, owner_class_name)) do 21 | include PropertySets::PropertySetModel::InstanceMethods 22 | extend PropertySets::PropertySetModel::ClassMethods 23 | end 24 | 25 | namespace.const_set(const_name, property_class) 26 | 27 | property_class.owner_class = owner_class_name 28 | property_class.owner_assoc = association 29 | end 30 | 31 | namespace.const_get(const_name.to_s) 32 | end 33 | 34 | def self.parent_for_property_class(namespace, owner_class_name) 35 | owner_class = namespace.const_get(owner_class_name) 36 | 37 | owner_class.property_sets_connection_class || owner_class.connection_class_for_self 38 | rescue NameError 39 | ::ActiveRecord::Base 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/property_sets/action_view_extension.rb: -------------------------------------------------------------------------------- 1 | require "action_view" 2 | 3 | module ActionView 4 | module Helpers 5 | class FormBuilder 6 | class PropertySetFormBuilderProxy 7 | attr_reader :property_set, :template, :object_name, :object 8 | 9 | def initialize(property_set, template, object_name, object) 10 | @property_set = property_set 11 | @template = template 12 | @object_name = object_name 13 | @object = object 14 | end 15 | 16 | def check_box(property, options = {}, checked_value = "1", unchecked_value = "0") 17 | options = prepare_options(property, options) do |properties| 18 | properties.send(:"#{property}?") 19 | end 20 | template.check_box(object_name, property, options, checked_value, unchecked_value) 21 | end 22 | 23 | def radio_button(property, checked_value = "1", options = {}) 24 | options[:id] ||= "#{object_name}_#{property_set}_#{property}_#{checked_value}" 25 | options = prepare_options(property, options) do |properties| 26 | properties.send(property.to_s) == checked_value 27 | end 28 | template.radio_button(object_name, property, checked_value, options) 29 | end 30 | 31 | def text_field(property, options = {}) 32 | template.text_field(object_name, property, prepare_id_name(property, options)) 33 | end 34 | 35 | def hidden_field(property, options = {}) 36 | options = prepare_id_name(property, options) 37 | unless options.key?(:value) 38 | options[:value] = cast_boolean(options[:object].send(property_set).send(property)) 39 | end 40 | template.hidden_field(object_name, property, options) 41 | end 42 | 43 | def select(property, choices, options = {}, html_options = {}) 44 | options = prepare_id_name(property, options) 45 | current_value = options[:object].send(property_set).send(property) 46 | template.select("#{object_name}[#{property_set}]", property, choices, {selected: current_value}, html_options) 47 | end 48 | 49 | private 50 | 51 | def prepare_id_name(property, options) 52 | throw "Invalid options type #{options.inspect}" unless options.is_a?(Hash) 53 | 54 | options.clone.tap do |prepared_options| 55 | prepared_options[:object] = object || fetch_target_object 56 | prepared_options[:id] ||= "#{object_name}_#{property_set}_#{property}" 57 | prepared_options[:name] = "#{object_name}[#{property_set}][#{property}]" 58 | end 59 | end 60 | 61 | def fetch_target_object 62 | instance = template.instance_variable_get(:"@#{object_name}") 63 | 64 | throw "No @#{object_name} in scope" if instance.nil? 65 | throw "The property_set_check_box only works on models with property set #{property_set}" unless instance.respond_to?(property_set) 66 | 67 | instance 68 | end 69 | 70 | def prepare_options(property, options, &block) 71 | options = prepare_id_name(property, options) 72 | options[:checked] = yield(options[:object].send(property_set)) 73 | options 74 | end 75 | 76 | def cast_boolean(value) 77 | case value 78 | when TrueClass then "1" 79 | when FalseClass then "0" 80 | else value 81 | end 82 | end 83 | end 84 | 85 | def property_set(identifier) 86 | PropertySetFormBuilderProxy.new(identifier, @template, object_name, object) 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/property_sets/active_record_extension.rb: -------------------------------------------------------------------------------- 1 | require "active_record" 2 | require "property_sets/casting" 3 | 4 | module PropertySets 5 | module ActiveRecordExtension 6 | module ClassMethods 7 | attr_accessor :property_sets_connection_class 8 | 9 | def property_set(association, options = {}, &block) 10 | unless include?(PropertySets::ActiveRecordExtension::InstanceMethods) 11 | send(:prepend, PropertySets::ActiveRecordExtension::InstanceMethods) 12 | cattr_accessor :property_set_index 13 | self.property_set_index = Set.new 14 | end 15 | 16 | raise "Invalid association name, letters only" unless /[a-z]+/.match?(association.to_s) 17 | exists = property_set_index.include?(association) 18 | 19 | property_set_index << association 20 | 21 | # eg AccountSetting - this IS idempotent 22 | property_class = PropertySets.ensure_property_set_class( 23 | association, 24 | options.delete(:owner_class_name) || name 25 | ) 26 | 27 | # eg property :is_awesome 28 | property_class.instance_eval(&block) if block 29 | 30 | tb_name = options.delete :table_name 31 | property_class.table_name = tb_name if tb_name 32 | 33 | hash_opts = { 34 | class_name: property_class.name, 35 | autosave: true, 36 | dependent: :destroy, 37 | inverse_of: name.demodulize.underscore.to_sym 38 | }.merge(options) 39 | 40 | # TODO: should check options are compatible? warn? raise? 41 | reflection = reflections[association.to_s] # => ActiveRecord::Reflection::HasManyReflection 42 | reflection.options.merge! options if reflection && !options.empty? 43 | 44 | unless exists # makes has_many idempotent... 45 | has_many association, **hash_opts do 46 | # keep this damn block! -- creates association_module below 47 | end 48 | end 49 | 50 | # stolen/adapted from AR's collection_association.rb #define_extensions 51 | 52 | module_name = "#{association.to_s.camelize}AssociationExtension" 53 | association_module = const_get module_name 54 | 55 | association_module.module_eval do 56 | include PropertySets::ActiveRecordExtension::AssociationExtensions 57 | 58 | property_class.keys.each do |key| 59 | raise "Invalid property key #{key}" if respond_to?(key) 60 | 61 | # Reports the coerced truth value of the property 62 | define_method :"#{key}?" do 63 | type = property_class.type(key) 64 | value = lookup_value(type, key) 65 | !["false", "0", "", "off", "n"].member?(value.to_s.downcase) 66 | end 67 | 68 | # Returns the value of the property 69 | define_method key.to_s do 70 | type = property_class.type(key) 71 | lookup_value(type, key) 72 | end 73 | 74 | # Assigns a new value to the property 75 | define_method :"#{key}=" do |value| 76 | instance = lookup(key) 77 | instance.value = PropertySets::Casting.write(property_class.type(key), value) 78 | instance.value 79 | end 80 | 81 | define_method :"#{key}_record" do 82 | lookup(key) 83 | end 84 | end 85 | 86 | define_method :property_serialized? do |key| 87 | property_class.type(key) == :serialized 88 | end 89 | end 90 | end 91 | end 92 | 93 | module AssociationExtensions 94 | # Accepts an array of names as strings or symbols and returns a hash. 95 | def get(keys = []) 96 | property_keys = if keys.empty? 97 | association_class.keys 98 | else 99 | association_class.keys & keys.map(&:to_s) 100 | end 101 | 102 | property_pairs = property_keys.flat_map do |name| 103 | value = lookup_value(association_class.type(name), name) 104 | [name, value] 105 | end 106 | HashWithIndifferentAccess[*property_pairs] 107 | end 108 | 109 | # Accepts a name value pair hash { :name => 'value', :pairs => true } and builds a property for each key 110 | def set(property_pairs, with_protection = false) 111 | property_pairs.keys.each do |name| 112 | record = lookup(name) 113 | if with_protection && record.protected? 114 | association_class.logger.warn("Someone tried to update the protected #{name} property to #{property_pairs[name]}") 115 | else 116 | send(:"#{name}=", property_pairs[name]) 117 | end 118 | end 119 | end 120 | 121 | def save(...) 122 | each { |p| p.save(...) } 123 | end 124 | 125 | def save!(...) 126 | each { |p| p.save!(...) } 127 | end 128 | 129 | def protected?(arg) 130 | lookup(arg).protected? 131 | end 132 | 133 | def enable(arg) 134 | send(:"#{arg}=", "1") 135 | end 136 | 137 | def disable(arg) 138 | send(:"#{arg}=", "0") 139 | end 140 | 141 | def build_default(arg) 142 | build(name: arg.to_s, value: association_class.raw_default(arg)) 143 | end 144 | 145 | def lookup_without_default(arg) 146 | detect { |property| property.name.to_sym == arg.to_sym } 147 | end 148 | 149 | def lookup_value(type, key) 150 | serialized = property_serialized?(key) 151 | 152 | if (instance = lookup_without_default(key)) 153 | instance.value_serialized = serialized 154 | PropertySets::Casting.read(type, instance.value) 155 | else 156 | value = association_class.default(key) 157 | if serialized 158 | PropertySets::Casting.deserialize(value) 159 | else 160 | PropertySets::Casting.read(type, value) 161 | end 162 | end 163 | end 164 | 165 | # The finder method which returns the property if present, otherwise a new instance with defaults 166 | def lookup(arg) 167 | instance = lookup_without_default(arg) 168 | instance ||= build_default(arg) 169 | instance.value_serialized = property_serialized?(arg) 170 | 171 | owner = proxy_association.owner 172 | 173 | instance.send(:"#{association_class.owner_class_sym}=", owner) if owner.new_record? 174 | instance 175 | end 176 | 177 | # This finder method returns the property if present, otherwise a new instance with the default value. 178 | # It does not have the side effect of adding a new setting object. 179 | def lookup_or_default(arg) 180 | instance = lookup_without_default(arg) 181 | instance ||= association_class.new(value: association_class.raw_default(arg)) 182 | instance.value_serialized = property_serialized?(arg) 183 | instance 184 | end 185 | 186 | def association_class 187 | @association_class ||= proxy_association.klass 188 | end 189 | end 190 | 191 | module InstanceMethods 192 | def update(attributes) 193 | update_property_set_attributes(attributes) 194 | super 195 | end 196 | alias_method :update_attributes, :update 197 | 198 | def update!(attributes) 199 | update_property_set_attributes(attributes) 200 | super 201 | end 202 | alias_method :update_attributes!, :update! 203 | 204 | def update_property_set_attributes(attributes) 205 | if attributes && self.class.property_set_index.any? 206 | self.class.property_set_index.each do |property_set| 207 | if (property_set_hash = attributes.delete(property_set)) 208 | send(property_set).set(property_set_hash, true) 209 | end 210 | end 211 | end 212 | end 213 | 214 | def update_columns(attributes) 215 | if delegated_property_sets? 216 | attributes = attributes.reject { |k, _| self.class.delegated_property_set_attributes.include?(k.to_s) } 217 | end 218 | 219 | super 220 | end 221 | 222 | private 223 | 224 | def delegated_property_sets? 225 | self.class.respond_to?(:delegated_property_set_attributes) 226 | end 227 | 228 | def attributes_for_create(attribute_names) 229 | super(filter_delegated_property_set_attributes(attribute_names)) 230 | end 231 | 232 | def attributes_for_update(attribute_names) 233 | super(filter_delegated_property_set_attributes(attribute_names)) 234 | end 235 | 236 | def filter_delegated_property_set_attributes(attribute_names) 237 | if delegated_property_sets? 238 | return attribute_names - self.class.delegated_property_set_attributes.to_a 239 | end 240 | attribute_names 241 | end 242 | end 243 | end 244 | end 245 | 246 | ActiveRecord::Base.extend PropertySets::ActiveRecordExtension::ClassMethods 247 | -------------------------------------------------------------------------------- /lib/property_sets/casting.rb: -------------------------------------------------------------------------------- 1 | require "json" 2 | 3 | module PropertySets 4 | module Casting 5 | FALSE_VALUES = ["false", "0", "", "off", "n"] 6 | 7 | class << self 8 | def read(type, value) 9 | return nil if value.nil? 10 | 11 | case type 12 | when :string 13 | value 14 | when :datetime 15 | Time.parse(value).in_time_zone 16 | when :float 17 | value.to_f 18 | when :integer 19 | value.to_i 20 | when :boolean 21 | !false?(value) 22 | when :serialized 23 | # deserialization happens in the model 24 | value 25 | end 26 | end 27 | 28 | def write(type, value) 29 | return nil if value.nil? 30 | 31 | case type 32 | when :datetime 33 | if value.is_a?(String) 34 | value 35 | else 36 | value.in_time_zone("UTC").to_s 37 | end 38 | when :serialized 39 | # write the object directly. 40 | value 41 | when :boolean 42 | false?(value) ? "0" : "1" 43 | else 44 | value.to_s 45 | end 46 | end 47 | 48 | def deserialize(value) 49 | return nil if value.nil? || value == "null" 50 | JSON.parse(value) 51 | end 52 | 53 | private 54 | 55 | def false?(value) 56 | FALSE_VALUES.include?(value.to_s.downcase) 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/property_sets/delegator.rb: -------------------------------------------------------------------------------- 1 | module PropertySets 2 | module Delegator 3 | # methods for moving what was once a literal column on 4 | # to a property_set table. 5 | # 6 | # delegates read, write and query methods to the property record or the property default 7 | # 8 | # Examples 9 | # 10 | # # Migrate :is_open to the :settings property set, and rename it :open, 11 | # # and migrate :same to property set :same 12 | # include PropertySets::Delegator 13 | # delegate_to_property_set :settings, :is_open => :open, :same => :same 14 | # 15 | def self.included(base) 16 | base.extend(ClassMethods) 17 | end 18 | 19 | module ClassMethods 20 | def delegate_to_property_set(setname, mappings) 21 | raise "Second argument must be a Hash" unless mappings.is_a?(Hash) 22 | 23 | unless respond_to?(:delegated_property_set_attributes) 24 | class_attribute :delegated_property_set_attributes 25 | end 26 | self.delegated_property_set_attributes ||= [] 27 | 28 | mappings.each do |old_attr, new_attr| 29 | self.delegated_property_set_attributes << old_attr.to_s 30 | attribute old_attr, ActiveModel::Type::Value.new 31 | define_method(old_attr) { 32 | association = send(setname) 33 | type = association.association_class.type(new_attr) 34 | association.lookup_value(type, new_attr) 35 | } 36 | alias_method :"#{old_attr}_before_type_cast", old_attr 37 | define_method(:"#{old_attr}?") { send(setname).send(:"#{new_attr}?") } 38 | define_method(:"#{old_attr}=") do |value| 39 | if send(old_attr) != value 40 | send(:"#{old_attr}_will_change!") 41 | end 42 | send(setname).send(:"#{new_attr}=", value) 43 | super(value) 44 | end 45 | 46 | define_method(:"#{old_attr}_will_change!") do 47 | attribute_will_change!(old_attr) 48 | end 49 | 50 | define_method(:"#{old_attr}_changed?") do 51 | collection_proxy = send(setname) 52 | return false unless collection_proxy.loaded? 53 | setting = collection_proxy.lookup_without_default(new_attr) 54 | 55 | if !setting 56 | false # Nothing has been set which means that the attribute hasn't changed 57 | elsif setting.new_record? 58 | collection_proxy.association_class.default(new_attr) != setting.value 59 | else 60 | setting.value_changed? 61 | end 62 | end 63 | end 64 | 65 | # These are not database columns and should not be included in queries but 66 | # using the attributes API is the only way to track changes in the main model 67 | if respond_to?(:user_provided_columns) 68 | user_provided_columns.reject! { |k, _| delegated_property_set_attributes.include?(k.to_s) } 69 | end 70 | end 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/property_sets/property_set_model.rb: -------------------------------------------------------------------------------- 1 | require "active_support" 2 | 3 | module PropertySets 4 | module PropertySetModel 5 | # https://dev.mysql.com/doc/refman/5.6/en/storage-requirements.html 6 | COLUMN_TYPE_LIMITS = { 7 | "tinyblob" => 255, # 2^8 - 1 8 | "tinytext" => 255, 9 | "blob" => 65535, # 2^16 - 1 10 | "text" => 65535, 11 | "mediumblob" => 16777215, # 2^24 - 1 12 | "mediumtext" => 16777215, 13 | "longblob" => 4294967295, # 2^32 - 1 14 | "longtext" => 4294967295 15 | }.freeze 16 | 17 | module InstanceMethods 18 | def false? 19 | ["false", "0", "", "off", "n"].member?(value.to_s.downcase) 20 | end 21 | 22 | def true? 23 | !false? 24 | end 25 | 26 | def enable 27 | update_attribute(:value, "1") 28 | self 29 | end 30 | 31 | def disable 32 | update_attribute(:value, "0") 33 | self 34 | end 35 | 36 | def protected? 37 | self.class.protected?(name.to_sym) 38 | end 39 | 40 | def value 41 | if value_serialized 42 | v = read_attribute(:value) 43 | @deserialized_value ||= PropertySets::Casting.deserialize(v) 44 | else 45 | super 46 | end 47 | end 48 | 49 | def value=(v) 50 | if value_serialized 51 | @deserialized_value = v 52 | write_attribute(:value, v.to_json) 53 | else 54 | super 55 | end 56 | end 57 | 58 | def reload(*args, &block) 59 | @deserialized_value = nil 60 | super 61 | end 62 | 63 | def to_s 64 | value.to_s 65 | end 66 | 67 | attr_accessor :value_serialized 68 | 69 | private 70 | 71 | def validate_format_of_name 72 | if name.blank? 73 | errors.add(:name, :blank) 74 | elsif !name.is_a?(String) || name !~ /^([a-z0-9]+_?)+$/ 75 | errors.add(:name, :invalid) 76 | end 77 | end 78 | 79 | def validate_length_of_serialized_data 80 | if value_serialized && read_attribute(:value).to_s.size > value_column_limit 81 | errors.add(:value, :invalid) 82 | end 83 | end 84 | 85 | def coerce_value 86 | if value && !value_serialized 87 | self.value = value.to_s 88 | end 89 | end 90 | 91 | def owner_class_instance 92 | send(self.class.owner_class_sym) 93 | end 94 | 95 | def value_column_limit 96 | column = self.class.columns_hash.fetch("value") 97 | 98 | # use sql_type because type returns :text for all text types regardless of length 99 | column.limit || COLUMN_TYPE_LIMITS.fetch(column.sql_type) 100 | end 101 | end 102 | 103 | module ClassMethods 104 | def self.extended(base) 105 | base.validate :validate_format_of_name 106 | base.validate :validate_length_of_serialized_data 107 | base.before_create :coerce_value 108 | base.attr_accessible :name, :value if defined?(ProtectedAttributes) 109 | end 110 | 111 | def properties 112 | @properties ||= HashWithIndifferentAccess.new 113 | end 114 | 115 | def property(key, options = nil) 116 | properties[key] = options 117 | end 118 | 119 | def keys 120 | properties.keys 121 | end 122 | 123 | def default(key) 124 | PropertySets::Casting.read(type(key), raw_default(key)) 125 | end 126 | 127 | def raw_default(key) 128 | properties[key].try(:[], :default) 129 | end 130 | 131 | def type(key) 132 | properties[key].try(:[], :type) || :string 133 | end 134 | 135 | def protected?(key) 136 | properties[key].try(:[], :protected) || false 137 | end 138 | 139 | def owner_class=(owner_class_name) 140 | @owner_class_sym = owner_class_name.to_s.demodulize.underscore.to_sym 141 | 142 | belongs_to owner_class_sym, class_name: owner_class_name 143 | validates_presence_of owner_class_sym, class_name: owner_class_name 144 | validates_uniqueness_of :name, scope: owner_class_key_sym, case_sensitive: false 145 | attr_accessible owner_class_key_sym, owner_class_sym if defined?(ProtectedAttributes) 146 | end 147 | 148 | def owner_assoc=(association) 149 | @owner_assoc = association 150 | end 151 | 152 | def owner_assoc 153 | @owner_assoc 154 | end 155 | 156 | def owner_class_sym 157 | @owner_class_sym 158 | end 159 | 160 | def owner_class_key_sym 161 | :"#{owner_class_sym}_id" 162 | end 163 | end 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /lib/property_sets/version.rb: -------------------------------------------------------------------------------- 1 | module PropertySets 2 | VERSION = "3.13.0" 3 | end 4 | -------------------------------------------------------------------------------- /property_sets.gemspec: -------------------------------------------------------------------------------- 1 | require "./lib/property_sets/version" 2 | 3 | Gem::Specification.new "property_sets", PropertySets::VERSION do |s| 4 | s.summary = "Property sets for ActiveRecord." 5 | s.description = "This gem is an ActiveRecord extension which provides a convenient interface for managing per row properties." 6 | s.authors = ["Morten Primdahl"] 7 | s.email = "primdahl@me.com" 8 | s.homepage = "http://github.com/zendesk/property_sets" 9 | s.license = "Apache License Version 2.0" 10 | 11 | s.required_ruby_version = ">= 3.1" 12 | 13 | s.add_runtime_dependency("activerecord", ">= 6.1") 14 | s.add_runtime_dependency("json") 15 | 16 | s.add_development_dependency("bump") 17 | s.add_development_dependency("rake") 18 | s.add_development_dependency("actionpack") 19 | s.add_development_dependency("rspec") 20 | s.add_development_dependency("standard") 21 | s.add_development_dependency("byebug") 22 | 23 | s.files = `git ls-files lib`.split("\n") 24 | s.license = "MIT" 25 | end 26 | -------------------------------------------------------------------------------- /spec/casting_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "property_sets/casting" 3 | 4 | describe PropertySets::Casting do 5 | describe "#read" do 6 | it "return nil when given value nil regardless of type" do 7 | expect(PropertySets::Casting.read(:string, nil)).to be_nil 8 | expect(PropertySets::Casting.read(:hello, nil)).to be_nil 9 | end 10 | 11 | it "leave serialized data alone" do 12 | expect(PropertySets::Casting.read(:serialized, [1, 2, 3])).to eq([1, 2, 3]) 13 | end 14 | 15 | it "reads boolean" do 16 | expect(PropertySets::Casting.read(:boolean, "true")).to be true 17 | expect(PropertySets::Casting.read(:boolean, "1")).to be true 18 | expect(PropertySets::Casting.read(:boolean, "something")).to be true 19 | expect(PropertySets::Casting.read(:boolean, "on")).to be true 20 | expect(PropertySets::Casting.read(:boolean, true)).to be true 21 | expect(PropertySets::Casting.read(:boolean, 1111)).to be true 22 | end 23 | end 24 | 25 | describe "#write" do 26 | it "return nil when given value nil regardless of type" do 27 | expect(PropertySets::Casting.write(:string, nil)).to be_nil 28 | expect(PropertySets::Casting.write(:hello, nil)).to be_nil 29 | end 30 | 31 | it "convert time instances to UTC" do 32 | time = Time.now.in_time_zone("CET") 33 | expect(PropertySets::Casting.write(:datetime, time)).to match(/UTC$/) 34 | end 35 | 36 | it "convert integers to strings" do 37 | expect(PropertySets::Casting.write(:integer, 123)).to eq("123") 38 | end 39 | 40 | it "convert random things to booleans" do 41 | expect(PropertySets::Casting.write(:boolean, 1)).to eq("1") 42 | expect(PropertySets::Casting.write(:boolean, true)).to eq("1") 43 | expect(PropertySets::Casting.write(:boolean, "dfsdff")).to eq("1") 44 | 45 | expect(PropertySets::Casting.write(:boolean, "")).to eq("0") 46 | expect(PropertySets::Casting.write(:boolean, nil)).to be_nil 47 | expect(PropertySets::Casting.write(:boolean, false)).to eq("0") 48 | expect(PropertySets::Casting.write(:boolean, 0)).to eq("0") 49 | expect(PropertySets::Casting.write(:boolean, "off")).to eq("0") 50 | expect(PropertySets::Casting.write(:boolean, "n")).to eq("0") 51 | end 52 | 53 | it "leave serialized data alone for the record to deal with" do 54 | a = [123] 55 | expect(PropertySets::Casting.write(:serialized, a)).to eq(a) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/delegator_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe PropertySets::Delegator do 4 | let(:account) { Parent::Account.create(name: "Name") } 5 | let(:default) { "skep" } 6 | 7 | describe "read" do 8 | it "not add a property" do 9 | account.old 10 | expect(account.settings.size).to eq(0) 11 | end 12 | 13 | it "delegate read to default" do 14 | expect(account.old).to eq(default) 15 | end 16 | 17 | it "delegate read to property value" do 18 | account.settings.hep = "new" 19 | expect(account.old).to eq("new") 20 | end 21 | end 22 | 23 | describe "write" do 24 | it "add a property" do 25 | account.old = "new" 26 | expect(account.settings.size).to eq(1) 27 | end 28 | 29 | it "delegate write" do 30 | account.old = "new" 31 | expect(account.settings.hep).to eq("new") 32 | expect(account.old).to eq("new") 33 | end 34 | end 35 | 36 | describe "changed?" do 37 | it "does not add a property" do 38 | account.old_changed? 39 | expect(account.settings.size).to eq(0) 40 | end 41 | 42 | it "is not changed when unchanged" do 43 | expect(account.old_changed?).to be false 44 | end 45 | 46 | it "is changed with new value" do 47 | account.old = "new" 48 | expect(account.old_changed?).to be true 49 | end 50 | 51 | it "is changed with new falsy value" do 52 | account.old = false 53 | expect(account.old_changed?).to be true 54 | end 55 | 56 | it "is changed with new nil value" do 57 | account.old = nil 58 | expect(account.old_changed?).to be true 59 | end 60 | 61 | it "is not changed with default value" do 62 | account.old = default 63 | expect(account.old_changed?).to be false 64 | end 65 | 66 | it "does not perform queries when association was never loaded and could not possibly be changed" do 67 | account 68 | assert_sql_queries 0 do 69 | expect(account.old_changed?).to be false 70 | end 71 | end 72 | end 73 | 74 | describe "before_type_case" do 75 | it "not add a property" do 76 | account.old_before_type_cast 77 | expect(account.settings.size).to eq(0) 78 | end 79 | 80 | it "return default" do 81 | expect(account.old_before_type_cast).to eq(default) 82 | end 83 | 84 | it "return setting" do 85 | account.old = "new" 86 | expect(account.old_before_type_cast).to eq("new") 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /spec/inheritance_spec.rb: -------------------------------------------------------------------------------- 1 | require "active_support" 2 | require "active_record" 3 | require "property_sets" 4 | 5 | yaml_config = "spec/support/database.yml" 6 | ActiveRecord::Base.configurations = begin 7 | YAML.safe_load(IO.read(yaml_config), aliases: true) 8 | rescue ArgumentError 9 | YAML.safe_load(IO.read(yaml_config)) 10 | end 11 | 12 | class AbstractUnshardedModel < ActiveRecord::Base 13 | self.abstract_class = true 14 | 15 | connects_to database: {writing: :unsharded_database, reading: :unsharded_database_replica} 16 | end 17 | 18 | class Vehicle < AbstractUnshardedModel 19 | property_set :settings do 20 | property :type 21 | end 22 | end 23 | 24 | describe PropertySets do 25 | it "creates property_set model" do 26 | expect(defined?(VehicleSetting)).to be_truthy 27 | end 28 | 29 | it "inherits from a correct class" do 30 | if ActiveRecord.gem_version >= Gem::Version.new("6.1") 31 | expect(VehicleSetting.superclass).to be(AbstractUnshardedModel) 32 | else 33 | expect(VehicleSetting.superclass).to be(ActiveRecord::Base) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/property_sets_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | old, $-w = $-w, nil 4 | # sqlite type differences: 5 | PropertySets::PropertySetModel::COLUMN_TYPE_LIMITS = 6 | PropertySets::PropertySetModel::COLUMN_TYPE_LIMITS.merge("varchar" => 65535) 7 | $-w = old 8 | 9 | describe PropertySets do 10 | let(:account) { Parent::Account.create(name: "Name") } 11 | let(:relation) { Parent::Account.reflections["settings"] } 12 | 13 | it "construct the container class" do 14 | expect(defined?(Parent::AccountSetting)).to be_truthy 15 | expect(defined?(Parent::AccountText)).to be_truthy 16 | expect(defined?(Parent::AccountTypedDatum)).to be_truthy 17 | end 18 | 19 | it "register the property sets used on a class" do 20 | %i[settings texts validations typed_data].each do |name| 21 | expect(Parent::Account.property_set_index).to include(name) 22 | end 23 | end 24 | 25 | it "sets inverse_of" do 26 | expect(relation.inverse_of.klass).to eq Parent::Account 27 | end 28 | 29 | it "reopening property_set is idempotent, first one wins on options etc" do 30 | expect(Array(relation.options[:extend])).to include Parent::Account::Woot 31 | expect(account.settings.extensions).to include Parent::Account::Woot 32 | end 33 | 34 | it "allow the owner class to be customized" do 35 | (Flux = Class.new(ActiveRecord::Base)).property_set(:blot, { 36 | owner_class_name: "Foobar" 37 | }) { property :test } 38 | 39 | expect(defined?(FoobarBlot)).to be_truthy 40 | end 41 | 42 | it "pass-through any options from the second parameter" do 43 | class AnotherThing < MainDatabase # standard:disable Lint/ConstantDefinitionInBlock: 44 | self.table_name = "things" # cheat and reuse things table 45 | end 46 | 47 | AnotherThing.property_set(:settings, extend: Parent::Account::Woot, 48 | table_name: "thing_settings") 49 | 50 | expect(AnotherThing.new.settings.extensions).to include(::Parent::Account::Woot) 51 | end 52 | end 53 | 54 | RSpec.shared_examples "different account models" do |account_klass| 55 | let(:account) { account_klass.create(name: "Name") } 56 | let(:relation) { account_klass.reflections["settings"] } 57 | 58 | it "support protecting attributes" do 59 | expect(account.settings.protected?(:pro)).to be true 60 | expect(account.settings.protected?(:foo)).to be false 61 | end 62 | 63 | it "allow enabling/disabling a property" do 64 | expect(account.settings.hep?).to be true 65 | account.settings.disable(:hep) 66 | expect(account.settings.hep?).to be false 67 | account.settings.enable(:hep) 68 | expect(account.settings.hep?).to be true 69 | 70 | account = Parent::Account.new 71 | expect(account.settings.foo?).to be false 72 | account.settings.enable(:foo) 73 | expect(account.settings.foo?).to be true 74 | end 75 | 76 | it "be empty on a new account" do 77 | expect(account.settings).to be_empty 78 | expect(account.texts).to be_empty 79 | 80 | expect(account.texts.foo?).to be false 81 | expect(account.texts.bar?).to be false 82 | 83 | expect(account.texts.foo).to be_nil 84 | expect(account.texts.bar).to be_nil 85 | end 86 | 87 | it "respond with defaults" do 88 | expect(account.settings.bar?).to be false 89 | expect(account.settings.bar).to be_nil 90 | expect(account.settings.hep?).to be true 91 | expect(account.settings.hep).to eq("skep") 92 | expect(account.settings.bool_nil).to be_nil 93 | expect(account.settings.bool_nil2).to be_nil 94 | expect(account.settings.bool_false).to be false 95 | expect(account.settings.bool_true).to be true 96 | end 97 | 98 | it "be flexible when fetching property data" do 99 | expect(account.settings.association_class.default(:hep)).to eq("skep") 100 | expect(account.settings.association_class.default("hep")).to eq("skep") 101 | end 102 | 103 | describe "querying for a setting that does not exist" do 104 | before do 105 | expect(account.settings).to eq([]) 106 | expect(account.settings.hep?).to be true 107 | end 108 | 109 | it "not add a new setting" do 110 | expect(account.settings).to eq([]) 111 | end 112 | 113 | it "give back the default value" do 114 | expect(account.settings.hep).to eq("skep") 115 | end 116 | end 117 | 118 | it "reject settings with an invalid name" do 119 | # Because we are running these specs with two separate classes 120 | # (Parent::Account & Parent::AccountAltDb), we need to build the 121 | # settings class class name manually. 122 | settings_klass = Object.const_get("#{account_klass}Setting") # standard:disable Performance/StringIdentifierArgument 123 | s = settings_klass.new(account.model_name.element.to_sym => account) 124 | 125 | valids = %w[hello hel_lo hell0] + [:hello] 126 | invalids = %w[_hello] 127 | 128 | valids.each do |valid| 129 | s.name = valid 130 | expect(s).to be_valid, "#{valid} is invalid: #{s.errors.inspect}" 131 | end 132 | 133 | invalids.each do |invalid| 134 | s.name = invalid 135 | expect(s).to_not be_valid, "#{invalid} is valid" 136 | end 137 | end 138 | 139 | it "validate uniqueness of settings" do 140 | account.settings.create!(name: "unique") 141 | expect { 142 | account.settings.create!(name: "unique") 143 | }.to raise_error(ActiveRecord::RecordInvalid, /Name has already been taken/) 144 | end 145 | 146 | it "be creatable using the = operator" do 147 | expect(account.settings.foo?).to be false 148 | ["1", "2"].each do |value| 149 | expect(account.settings.foo = value).to be_truthy 150 | expect(account.settings.foo?).to be true 151 | expect(account.settings.size).to eq(1) 152 | end 153 | 154 | expect(account.texts).to be_empty 155 | end 156 | 157 | it "coerce everything but nil to string" do 158 | account.settings.foo = 3 159 | account.save 160 | expect(account.settings.foo).to eq("3") 161 | account.settings.foo = nil 162 | account.save 163 | expect(account.settings.foo).to be_nil 164 | end 165 | 166 | it "reference the owner instance when constructing a new record" do 167 | record = account.settings.lookup(:baz) 168 | expect(record).to be_new_record 169 | # Because we are running these specs with two separate classes 170 | # (Parent::Account & Parent::AccountAltDb), we need to build the 171 | # method name manually (:account vs :account_alt_db). 172 | expect(record.send(account.model_name.element.to_sym).id).to eq(account.id) 173 | end 174 | 175 | it "reference the owner instance when constructing a new record ...on a new record" do 176 | account = Parent::Account.new(name: "New") 177 | record = account.settings.lookup(:baz) 178 | 179 | expect(record).to be_new_record 180 | expect(record.account).to eq(account) 181 | end 182 | 183 | describe "validations" do 184 | it "add an error when violated" do 185 | account.validations.validated = "hello" 186 | expect(account).to_not be_valid 187 | expect(account.errors.full_messages.first).to match(/BEEP$/) 188 | end 189 | end 190 | 191 | describe "#get" do 192 | before { account.settings.set(baz: "456") } 193 | 194 | it "fetch property pairs with string arguments" do 195 | expect(account.settings.lookup_without_default(:baz)).to be_truthy 196 | expect(account.settings.get(["baz"])).to eq("baz" => "456") 197 | end 198 | 199 | it "fetch property pairs with symbol arguments" do 200 | expect(account.settings.get([:baz])).to eq("baz" => "456") 201 | end 202 | 203 | it "return all property pairs if no arguments are provided" do 204 | expect(account.settings.get.keys.sort).to eq( 205 | %w[bar baz bool_false bool_nil bool_nil2 bool_true foo hep pro].sort 206 | ) 207 | end 208 | 209 | it "ignore non-existent keys" do 210 | expect(account.settings.get([:baz, :red])).to eq("baz" => "456") 211 | end 212 | 213 | it "include default property pairs" do 214 | expect(account.settings.lookup_without_default(:hep)).to be_nil 215 | expect(account.settings.get(["hep"])).to eq("hep" => "skep") 216 | end 217 | 218 | it "return a hash with values that can be fetched by string or symbol" do 219 | expect(account.settings.get(["baz"]).fetch(:baz)).to eq("456") 220 | end 221 | 222 | it "return serialized values" do 223 | account.typed_data.set(serialized_prop: [1, 2]) 224 | expect(account.typed_data.lookup_without_default(:serialized_prop)).to be_truthy 225 | expect(account.typed_data.get([:serialized_prop])).to eq("serialized_prop" => [1, 2]) 226 | end 227 | end 228 | 229 | describe "#set" do 230 | it "support writing multiple values to the association" do 231 | expect(account.settings.foo?).to be_falsy 232 | expect(account.settings.bar?).to be_falsy 233 | 234 | account.settings.set(foo: "123", bar: "456") 235 | 236 | expect(account.settings.foo?).to be_truthy 237 | expect(account.settings.bar?).to be_truthy 238 | end 239 | 240 | it "convert string keys to symbols to ensure consistent lookup" do 241 | account.settings.set(foo: "123") 242 | account.settings.set("foo" => "456") 243 | expect(account.save!).to be true 244 | end 245 | 246 | it "work identically for new and existing owner objects" do 247 | [account, Parent::Account.new(name: "Mibble")].each do |account| 248 | account.settings.set(foo: "123", bar: "456") 249 | 250 | expect(account.settings.size).to eq(2) 251 | expect(account.settings.foo).to eq("123") 252 | expect(account.settings.bar).to eq("456") 253 | 254 | account.settings.set(bar: "789", baz: "012") 255 | 256 | expect(account.settings.size).to eq(3) 257 | expect(account.settings.foo).to eq("123") 258 | expect(account.settings.bar).to eq("789") 259 | expect(account.settings.baz).to eq("012") 260 | end 261 | end 262 | 263 | it "be updateable as AR nested attributes" do 264 | expect( 265 | account.texts_attributes = [{name: "foo", value: "1"}, {name: "bar", value: "0"}] 266 | ).to be_truthy 267 | 268 | account.save! 269 | 270 | expect(account.texts.foo).to eq("1") 271 | expect(account.texts.bar).to eq("0") 272 | 273 | account.update_attributes!(texts_attributes: [ 274 | {id: account.texts.lookup(:foo).id, name: "foo", value: "0"}, 275 | {id: account.texts.lookup(:bar).id, name: "bar", value: "1"} 276 | ]) 277 | 278 | expect(account.texts.foo).to eq("0") 279 | expect(account.texts.bar).to eq("1") 280 | end 281 | 282 | it "be updateable as a nested structure" do 283 | account.settings.baz = "1" 284 | account.save! 285 | 286 | expect(account.settings.foo?).to be false 287 | expect(account.settings.bar?).to be false 288 | expect(account.settings.baz?).to be true 289 | expect(account.settings.pro?).to be false 290 | 291 | account.update_attributes!( 292 | name: "Kim", 293 | settings: {foo: "1", baz: "0", pro: "1"} 294 | ) 295 | 296 | account.reload 297 | 298 | # set 299 | expect(account.settings.foo?).to be true 300 | expect(account.settings.foo).to eq("1") 301 | 302 | # kept 303 | expect(account.settings.bar?).to be false 304 | expect(account.settings.bar).to be_nil 305 | 306 | # unset 307 | expect(account.settings.baz?).to be false 308 | expect(account.settings.baz).to eq("0") 309 | 310 | # protected -> not set 311 | expect(account.settings.pro?).to be false 312 | expect(account.settings.pro).to be_nil 313 | end 314 | end 315 | 316 | describe "lookup" do 317 | describe "with data" do 318 | it "return the data" do 319 | account.texts.foo = "1" 320 | expect(account.texts.lookup(:foo).value).to eq("1") 321 | end 322 | 323 | it "returns false" do 324 | account.settings.bool_nil = false 325 | expect(account.settings.lookup(:bool_nil).value).to eq("0") 326 | end 327 | end 328 | 329 | describe "without data" do 330 | it "returns nil without default" do 331 | expect(account.texts.lookup(:foo).value).to be_nil 332 | end 333 | 334 | it "create a new record" do 335 | expect(account.texts.detect { |p| p.name == "foo" }).to be_falsy 336 | account.texts.lookup(:foo).value 337 | expect(account.texts.detect { |p| p.name == "foo" }).to be_truthy 338 | end 339 | 340 | it "returns nil with default" do 341 | expect(account.texts.lookup(:hep).value).to be_nil 342 | end 343 | 344 | it "returns nil with default for booleans" do 345 | expect(account.texts.lookup(:bool_false).value).to be_nil 346 | end 347 | end 348 | end 349 | 350 | describe "lookup_without_default" do 351 | it "return the row if it exists" do 352 | account.texts.foo = "1" 353 | expect(account.texts.lookup_without_default(:foo).value).to eq("1") 354 | end 355 | 356 | it "return nil otherwise" do 357 | expect(account.texts.lookup_without_default(:foo)).to be_nil 358 | end 359 | end 360 | 361 | describe "save" do 362 | it "call save on all dem records" do 363 | account.settings.foo = "1" 364 | account.settings.bar = "2" 365 | account.settings.save 366 | 367 | account.reload 368 | expect(account.settings.foo).to eq("1") 369 | expect(account.settings.bar).to eq("2") 370 | end 371 | 372 | it "sets forwarded attributes" do 373 | other_account = Other::Account.new(name: "creating", old: "forwarded value") 374 | other_account.save 375 | expect(other_account.old).to eq("forwarded value") 376 | end 377 | end 378 | 379 | describe "update_attribute for forwarded method" do 380 | it "creates changed attributes" do 381 | account.update_attribute(:old, "it works!") 382 | expect(account.previous_changes["old"].last).to eq("it works!") 383 | expect(account_klass.find(account.id).old).to eq("it works!") 384 | end 385 | 386 | it "updates changed attributes for existing property_set data" do 387 | account.settings.hep = "saved previously" 388 | account.save 389 | account.update_attribute(:old, "it works!") 390 | expect(account.previous_changes["old"].last).to eq("it works!") 391 | expect(account_klass.find(account.id).old).to eq("it works!") 392 | end 393 | 394 | it "updates changed attributes for existing property_set data after set through forwarded method" do 395 | account.old = "saved previously" 396 | account.save 397 | account.update_attribute(:old, "it works!") 398 | expect(account.previous_changes["old"].last).to eq("it works!") 399 | expect(account_klass.find(account.id).old).to eq("it works!") 400 | end 401 | end 402 | 403 | describe "assign_attributes for forwarded method" do 404 | it "sets the attribute value" do 405 | account.assign_attributes(old: "assigned!") 406 | expect(account.old).to eq("assigned!") 407 | end 408 | 409 | it "sets the object's changed attributes" do 410 | account.assign_attributes(old: "assigned!") 411 | expect(account).to be_changed 412 | expect(account.changed_attributes).to include(:old) 413 | end 414 | end 415 | 416 | describe "update_columns for forwarded method" do 417 | it "does not write to a missing column" do 418 | account.update_columns(name: "test", old: "it works!") 419 | expect(account.previous_changes).to_not include("old") 420 | end 421 | 422 | it "does not prevent other non-delegated property set models from updating" do 423 | thing = Thing.create(name: "test") 424 | expect(thing.update_columns(name: "it works")).to be 425 | end 426 | end 427 | 428 | describe "typed columns" do 429 | it "typecast the default value" do 430 | expect(account.typed_data.association_class.default(:default_prop)).to eq(123) 431 | end 432 | 433 | describe "string data" do 434 | it "be writable and readable" do 435 | account.typed_data.string_prop = "foo" 436 | expect(account.typed_data.string_prop).to eq("foo") 437 | end 438 | end 439 | 440 | describe "floating point data" do 441 | it "be writable and readable" do 442 | account.typed_data.float_prop = 1.97898 443 | expect(account.typed_data.float_prop).to eq(1.97898) 444 | account.save! 445 | expect(account.typed_data.float_prop).to eq(1.97898) 446 | end 447 | end 448 | 449 | describe "integer data" do 450 | it "be writable and readable" do 451 | account.typed_data.int_prop = 25 452 | expect(account.typed_data.int_prop).to eq(25) 453 | account.save! 454 | expect(account.typed_data.int_prop).to eq(25) 455 | 456 | expect(account.typed_data.lookup("int_prop").value).to eq("25") 457 | end 458 | end 459 | 460 | describe "datetime data" do 461 | it "be writable and readable" do 462 | ts = Time.at(Time.now.to_i) 463 | account.typed_data.datetime_prop = ts 464 | 465 | expect(account.typed_data.datetime_prop).to eq(ts) 466 | account.save! 467 | expect(account.typed_data.datetime_prop).to eq(ts) 468 | end 469 | 470 | it "store data in UTC" do 471 | ts = Time.at(Time.now.to_i) 472 | string_rep = ts.in_time_zone("UTC").to_s 473 | account.typed_data.datetime_prop = ts 474 | account.save! 475 | expect(account.typed_data.lookup("datetime_prop").value).to eq(string_rep) 476 | end 477 | end 478 | 479 | describe "serialized data" do 480 | it "store data in json" do 481 | value = {a: 1, b: 2} 482 | account.typed_data.serialized_prop = value 483 | account.save! 484 | account.reload 485 | expect(account.typed_data.serialized_prop).to eq("a" => 1, "b" => 2) 486 | end 487 | 488 | it "retrieve default values from JSON" do 489 | expect(account.typed_data.serialized_prop_with_default).to eq([]) 490 | end 491 | 492 | it "not overflow the column" do 493 | account.typed_data.serialized_prop = (1..100_000).to_a 494 | expect(account.typed_data.lookup(:serialized_prop)).to_not be_valid 495 | expect(account.save).to be false 496 | end 497 | 498 | it "not overflow on other text types" do 499 | account.tiny_texts.serialized = (1..2**10).to_a # column size is 2^8 - 1 500 | expect(account.tiny_texts.lookup(:serialized)).to_not be_valid 501 | expect(account.save).to be false 502 | end 503 | 504 | it "allow for destructive operators" do 505 | value = {a: 1, b: 2} 506 | account.typed_data.serialized_prop = value 507 | account.typed_data.serialized_prop[:c] = 3 508 | expect(account.typed_data.serialized_prop[:c]).to eq(3) 509 | end 510 | 511 | it "deal with nil values properly going in" do 512 | account.typed_data.serialized_prop = nil 513 | expect { 514 | account.save! 515 | }.to_not raise_error 516 | end 517 | 518 | it "deal with nil values properly coming out" do 519 | expect(account.typed_data.serialized_prop).to be_nil 520 | end 521 | end 522 | end 523 | end 524 | 525 | describe Parent::Account do 526 | it_behaves_like "different account models", Parent::Account 527 | end 528 | 529 | describe Parent::AccountAltDb do 530 | it_behaves_like "different account models", Parent::AccountAltDb 531 | end 532 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | require "logger" 3 | require "active_support" 4 | require "active_record" 5 | require "active_record/fixtures" 6 | 7 | ENV["RAILS_ENV"] = "test" 8 | 9 | LEGACY_CONNECTION_HANDLING = (ENV["LEGACY_CONNECTION_HANDLING"] == "true") 10 | 11 | case "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}" 12 | when "7.0" 13 | ActiveRecord.legacy_connection_handling = LEGACY_CONNECTION_HANDLING 14 | when "6.1" 15 | ActiveRecord::Base.legacy_connection_handling = LEGACY_CONNECTION_HANDLING 16 | end 17 | 18 | require "property_sets" 19 | require "property_sets/delegator" 20 | 21 | require "support/database_config" 22 | require "support/models" 23 | require "support/database_migrations" 24 | 25 | I18n.enforce_available_locales = false 26 | 27 | # http://stackoverflow.com/questions/5490411/counting-the-number-of-queries-performed/43810063#43810063 28 | module QueryAssertions 29 | def sql_queries(&) 30 | queries = [] 31 | counter = ->(*, payload) { 32 | queries << payload.fetch(:sql) unless ["CACHE", "SCHEMA"].include?(payload.fetch(:name)) 33 | } 34 | 35 | ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &) 36 | 37 | queries 38 | end 39 | 40 | def assert_sql_queries(expected, &) 41 | queries = sql_queries(&) 42 | expect(queries.count).to eq(expected), "Expected #{expected} queries, but found #{queries.count}:\n#{queries.join("\n")}" 43 | end 44 | end 45 | 46 | module ActiveRecord 47 | module Associations 48 | class CollectionProxy 49 | def scoping 50 | raise "CollectionProxy delegates unknown methods to target (association_class) via method_missing, wrapping the call with `scoping`. Instead, call the method directly on the association_class!" 51 | end 52 | end 53 | end 54 | end 55 | 56 | RSpec.configure { |c| c.include QueryAssertions } 57 | -------------------------------------------------------------------------------- /spec/support/acts_like_an_integer.rb: -------------------------------------------------------------------------------- 1 | class ActsLikeAnInteger 2 | def to_i 3 | 123 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | unsharded_database: 3 | adapter: sqlite3 4 | encoding: utf8 5 | database: unsharded_database 6 | 7 | unsharded_database_replica: 8 | adapter: sqlite3 9 | encoding: utf8 10 | database: unsharded_database_replica 11 | replica: true 12 | -------------------------------------------------------------------------------- /spec/support/database_config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | config = { 4 | test: { 5 | test_database: { 6 | adapter: "sqlite3", 7 | database: ":memory:" 8 | }, 9 | test_alt_database: { 10 | adapter: "sqlite3", 11 | database: ":memory:" 12 | } 13 | } 14 | } 15 | 16 | ActiveRecord::Base.configurations = config 17 | -------------------------------------------------------------------------------- /spec/support/database_migrations.rb: -------------------------------------------------------------------------------- 1 | # setup database 2 | require "active_record" 3 | ActiveRecord::Base.logger = Logger.new($stdout) 4 | ActiveRecord::Base.logger.level = Logger::ERROR 5 | 6 | ActiveRecord::Base.establish_connection(:test_database) 7 | ActiveRecord::Base.connection.execute("select 1") 8 | 9 | ActiveRecord::Migration.verbose = false 10 | 11 | class CreatePrimaryTables < ActiveRecord::Migration[6.0] 12 | def connection 13 | MainDatabase.connection 14 | end 15 | 16 | def change 17 | create_table "account_benchmark_settings", force: true do |t| 18 | t.integer "account_id" 19 | t.string "name" 20 | t.string "value" 21 | t.datetime "created_at" 22 | t.datetime "updated_at" 23 | end 24 | 25 | add_index :account_benchmark_settings, [:account_id, :name], unique: true 26 | 27 | create_table "accounts", force: true do |t| 28 | t.string "name" 29 | t.datetime "created_at" 30 | t.datetime "updated_at" 31 | end 32 | 33 | create_table "account_alt_dbs", force: true do |t| 34 | t.string "name" 35 | t.datetime "created_at" 36 | t.datetime "updated_at" 37 | end 38 | 39 | create_table "account_settings", force: true do |t| 40 | t.integer "account_id" 41 | t.string "name" 42 | t.string "value" 43 | t.datetime "created_at" 44 | t.datetime "updated_at" 45 | end 46 | 47 | add_index :account_settings, [:account_id, :name], unique: true 48 | 49 | create_table "account_texts", force: true do |t| 50 | t.integer "account_id" 51 | t.string "name" 52 | t.string "value" 53 | t.datetime "created_at" 54 | t.datetime "updated_at" 55 | end 56 | 57 | add_index :account_texts, [:account_id, :name], unique: true 58 | 59 | create_table "account_typed_data", force: true do |t| 60 | t.integer "account_id" 61 | t.string "name" 62 | t.string "value" 63 | t.datetime "created_at" 64 | t.datetime "updated_at" 65 | end 66 | 67 | add_index :account_typed_data, [:account_id, :name], unique: true 68 | 69 | create_table "account_validations", force: true do |t| 70 | t.integer "account_id" 71 | t.string "name" 72 | t.string "value" 73 | t.datetime "created_at" 74 | t.datetime "updated_at" 75 | end 76 | 77 | add_index :account_validations, [:account_id, :name], unique: true 78 | 79 | create_table "account_tiny_texts", force: true do |t| 80 | t.integer "account_id" 81 | t.string "name" 82 | t.text "value", limit: (2**8 - 1) 83 | t.datetime "created_at" 84 | t.datetime "updated_at" 85 | end 86 | 87 | add_index :account_tiny_texts, [:account_id, :name], unique: true 88 | 89 | create_table "things", force: true do |t| 90 | t.string "name" 91 | t.datetime "created_at" 92 | t.datetime "updated_at" 93 | end 94 | 95 | create_table "thing_settings", force: true do |t| 96 | t.integer "thing_id" 97 | t.string "name" 98 | t.string "value" 99 | t.datetime "created_at" 100 | t.datetime "updated_at" 101 | end 102 | 103 | add_index :thing_settings, [:thing_id, :name], unique: true 104 | end 105 | end 106 | 107 | CreatePrimaryTables.migrate(:up) 108 | 109 | class CreateAccountAltDatabase < ActiveRecord::Migration[6.0] 110 | def connection 111 | AltDatabase.connection 112 | end 113 | 114 | def change 115 | create_table "account_alt_db_settings", force: true do |t| 116 | t.integer "account_alt_db_id" 117 | t.string "name" 118 | t.string "value" 119 | t.datetime "created_at" 120 | t.datetime "updated_at" 121 | end 122 | 123 | add_index :account_alt_db_settings, [:account_alt_db_id, :name], unique: true 124 | 125 | create_table "account_alt_db_texts", force: true do |t| 126 | t.integer "account_alt_db_id" 127 | t.string "name" 128 | t.string "value" 129 | t.datetime "created_at" 130 | t.datetime "updated_at" 131 | end 132 | 133 | add_index :account_alt_db_texts, [:account_alt_db_id, :name], unique: true 134 | 135 | create_table "account_alt_db_typed_data", force: true do |t| 136 | t.integer "account_alt_db_id" 137 | t.string "name" 138 | t.string "value" 139 | t.datetime "created_at" 140 | t.datetime "updated_at" 141 | end 142 | 143 | add_index :account_alt_db_typed_data, [:account_alt_db_id, :name], unique: true 144 | 145 | create_table "account_alt_db_validations", force: true do |t| 146 | t.integer "account_alt_db_id" 147 | t.string "name" 148 | t.string "value" 149 | t.datetime "created_at" 150 | t.datetime "updated_at" 151 | end 152 | 153 | add_index :account_alt_db_validations, [:account_alt_db_id, :name], unique: true 154 | 155 | create_table "account_alt_db_tiny_texts", force: true do |t| 156 | t.integer "account_alt_db_id" 157 | t.string "name" 158 | t.text "value", limit: (2**8 - 1) 159 | t.datetime "created_at" 160 | t.datetime "updated_at" 161 | end 162 | 163 | add_index :account_alt_db_tiny_texts, [:account_alt_db_id, :name], unique: true 164 | end 165 | end 166 | 167 | CreateAccountAltDatabase.migrate(:up) 168 | -------------------------------------------------------------------------------- /spec/support/models.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "acts_like_an_integer" 4 | 5 | if LEGACY_CONNECTION_HANDLING 6 | class MainDatabase < ActiveRecord::Base 7 | self.abstract_class = true 8 | end 9 | 10 | class AltDatabase < ActiveRecord::Base 11 | self.abstract_class = true 12 | establish_connection(:test_alt_database) 13 | end 14 | else 15 | class MainDatabase < ActiveRecord::Base 16 | self.abstract_class = true 17 | 18 | connects_to(database: {writing: :test_database, reading: :test_database}) 19 | end 20 | 21 | class AltDatabase < ActiveRecord::Base 22 | self.abstract_class = true 23 | 24 | connects_to(database: {writing: :test_alt_database, reading: :test_alt_database}) 25 | end 26 | end 27 | 28 | module Parent 29 | class Account < MainDatabase 30 | include PropertySets::Delegator 31 | 32 | delegate_to_property_set :settings, old: :hep 33 | 34 | # nonsense module to use in options below, only used as a marker 35 | module Woot # doesn't actually seem to be used in AR4 ? 36 | end 37 | 38 | property_set :settings, extend: Woot do 39 | property :foo 40 | property :bar 41 | property :baz 42 | property :hep, default: "skep" 43 | property :pro, protected: true 44 | property :bool_true, type: :boolean, default: true 45 | property :bool_false, type: :boolean, default: false 46 | property :bool_nil, type: :boolean, default: nil 47 | end 48 | 49 | property_set :settings do 50 | # reopening should maintain `extend` above 51 | property :bool_nil2, type: :boolean 52 | end 53 | 54 | property_set :texts do 55 | property :foo 56 | property :bar 57 | end 58 | 59 | accepts_nested_attributes_for :texts 60 | 61 | property_set :validations do 62 | property :validated 63 | property :regular 64 | 65 | validates_format_of :value, with: /\d+/, message: "BEEP", if: lambda { |r| r.name.to_sym == :validated } 66 | end 67 | 68 | property_set :typed_data do 69 | property :string_prop, type: :string 70 | property :datetime_prop, type: :datetime 71 | property :float_prop, type: :float 72 | property :int_prop, type: :integer 73 | property :serialized_prop, type: :serialized 74 | property :default_prop, type: :integer, default: ActsLikeAnInteger.new 75 | property :serialized_prop_with_default, type: :serialized, default: "[]" 76 | end 77 | 78 | property_set :tiny_texts do 79 | property :serialized, type: :serialized 80 | end 81 | end 82 | end 83 | 84 | module Other 85 | class Account < ::Parent::Account 86 | end 87 | end 88 | 89 | module Parent 90 | class AccountAltDb < MainDatabase 91 | include PropertySets::Delegator 92 | 93 | self.property_sets_connection_class = AltDatabase 94 | 95 | delegate_to_property_set :settings, old: :hep 96 | 97 | # nonsense module to use in options below, only used as a marker 98 | module Woot # doesn't actually seem to be used in AR4 ? 99 | end 100 | 101 | property_set :settings, extend: Woot do 102 | property :foo 103 | property :bar 104 | property :baz 105 | property :hep, default: "skep" 106 | property :pro, protected: true 107 | property :bool_true, type: :boolean, default: true 108 | property :bool_false, type: :boolean, default: false 109 | property :bool_nil, type: :boolean, default: nil 110 | end 111 | 112 | property_set :settings do 113 | # reopening should maintain `extend` above 114 | property :bool_nil2, type: :boolean 115 | end 116 | 117 | property_set :texts do 118 | property :foo 119 | property :bar 120 | end 121 | 122 | accepts_nested_attributes_for :texts 123 | 124 | property_set :validations do 125 | property :validated 126 | property :regular 127 | 128 | validates_format_of :value, with: /\d+/, message: "BEEP", if: lambda { |r| r.name.to_sym == :validated } 129 | end 130 | 131 | property_set :typed_data do 132 | property :string_prop, type: :string 133 | property :datetime_prop, type: :datetime 134 | property :float_prop, type: :float 135 | property :int_prop, type: :integer 136 | property :serialized_prop, type: :serialized 137 | property :default_prop, type: :integer, default: ActsLikeAnInteger.new 138 | property :serialized_prop_with_default, type: :serialized, default: "[]" 139 | end 140 | 141 | property_set :tiny_texts do 142 | property :serialized, type: :serialized 143 | end 144 | end 145 | end 146 | 147 | # No delegated property_set 148 | class Thing < MainDatabase 149 | property_set :settings do 150 | property :foo 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /spec/view_extensions_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe "property set view extensions" do 4 | def base_options 5 | { 6 | name: "#{object_name}[#{property_set}][#{property}]", 7 | id: "#{object_name}_#{property_set}_#{property}", 8 | object: object 9 | } 10 | end 11 | 12 | let(:property_set) { :settings } 13 | let(:property) { :active } 14 | let(:object_name) { "object_name" } 15 | let(:object) { double("View object") } 16 | let(:template) { double("Template") } 17 | let(:builder) { ActionView::Helpers::FormBuilder.new(object_name, object, template, {}) } 18 | let(:proxy) { builder.property_set(property_set) } 19 | 20 | it "provide a form builder proxy" do 21 | expect(proxy).to be_a(ActionView::Helpers::FormBuilder::PropertySetFormBuilderProxy) 22 | expect(proxy.property_set).to eq(property_set) 23 | end 24 | 25 | describe "object is not available" do 26 | let(:builder) { ActionView::Helpers::FormBuilder.new(object_name, nil, template, {}) } 27 | 28 | it "fetch the target object when not available" do 29 | allow(object).to receive(property_set).and_return(double("Fake property", property => "value")) 30 | allow(template).to receive(:hidden_field) 31 | 32 | expect(template).to receive(:instance_variable_get).with(:"@#{object_name}").and_return(object) 33 | proxy.hidden_field(property) 34 | end 35 | end 36 | 37 | describe "#check_box" do 38 | describe "when called with checked true for a truth value" do 39 | before do 40 | settings = double("Fake setting", property => "1", :"#{property}?" => true) 41 | allow(object).to receive(property_set).and_return(settings) 42 | end 43 | 44 | it "build a checkbox with the proper parameters" do 45 | expected_options = base_options.merge(checked: true) 46 | expect(template).to receive(:check_box).with(object_name, property, expected_options, "1", "0") 47 | proxy.check_box(property) 48 | end 49 | end 50 | 51 | describe "when called with checked false for a truth value" do 52 | before do 53 | settings = double("Fake setting", property => "0", :"#{property}?" => false) 54 | allow(object).to receive(property_set).and_return(settings) 55 | end 56 | 57 | it "build a checkbox with the proper parameters" do 58 | expected_options = base_options.merge(checked: false) 59 | expect(template).to receive(:check_box).with(object_name, property, expected_options, "1", "0") 60 | proxy.check_box(property) 61 | end 62 | end 63 | end 64 | 65 | describe "#hidden_field" do 66 | describe "when the persisted value is not a boolean" do 67 | before do 68 | settings = double("Fake property", property => "persisted value") 69 | allow(object).to receive(property_set).and_return(settings) 70 | end 71 | 72 | it "build a hidden field with the persisted value" do 73 | expected_options = base_options.merge(value: "persisted value") 74 | expect(template).to receive(:hidden_field).with(object_name, property, expected_options) 75 | proxy.hidden_field(property) 76 | end 77 | 78 | describe "and a value is provided" do 79 | it "build a hidden field with the provided value" do 80 | expected_options = base_options.merge(value: "provided value") 81 | expect(template).to receive(:hidden_field).with(object_name, property, expected_options) 82 | proxy.hidden_field(property, {value: "provided value"}) 83 | end 84 | end 85 | end 86 | 87 | describe "when the persisted value is a boolean" do 88 | it "build a hidden field with cast boolean value if it is a boolean true" do 89 | settings = double("Fake property", property => true) 90 | allow(object).to receive(property_set).and_return(settings) 91 | 92 | expected_options = base_options.merge(value: "1") 93 | expect(template).to receive(:hidden_field).with(object_name, property, expected_options) 94 | proxy.hidden_field(property) 95 | end 96 | 97 | it "build a hidden field with cast boolean value if it is a boolean false" do 98 | settings = double("Fake property", property => false) 99 | allow(object).to receive(property_set).and_return(settings) 100 | 101 | expected_options = base_options.merge(value: "0") 102 | expect(template).to receive(:hidden_field).with(object_name, property, expected_options) 103 | proxy.hidden_field(property) 104 | end 105 | end 106 | end 107 | 108 | describe "#text_field" do 109 | describe "when called with a provided value" do 110 | before do 111 | settings = double("Fake property", property => "persisted value") 112 | allow(object).to receive(property_set).and_return(settings) 113 | end 114 | 115 | it "build a text field with the provided value" do 116 | expected_options = base_options.merge(value: "provided value") 117 | expect(template).to receive(:text_field).with(object_name, property, expected_options) 118 | proxy.text_field(property, {value: "provided value"}) 119 | end 120 | end 121 | end 122 | 123 | describe "#radio_button" do 124 | let(:expected_options) { 125 | base_options.merge( 126 | id: "#{object_name}_#{property_set}_#{property}_hello", 127 | checked: false 128 | ) 129 | } 130 | 131 | let(:faked_property) { double("Fake property", property => "hello") } 132 | 133 | before do 134 | allow(object).to receive(property_set).and_return(faked_property) 135 | end 136 | 137 | it "generate a unique id when one is not provided" do 138 | expected_options[:id] = "#{object_name}_#{property_set}_#{property}_pancake" 139 | expect(template).to receive(:radio_button).with(object_name, property, "pancake", expected_options) 140 | proxy.radio_button(property, "pancake") 141 | end 142 | 143 | describe "when called with checked true for a truth value" do 144 | it "call with checked true for a truth value" do 145 | expected_options[:checked] = true 146 | expect(template).to receive(:radio_button).with(object_name, property, "hello", expected_options) 147 | proxy.radio_button(property, "hello") 148 | end 149 | end 150 | 151 | describe "when called with a value of a different type" do 152 | let(:faked_property) { double("Fake property", property => "1") } 153 | 154 | it "call with checked false" do 155 | expected_options[:id] = "#{object_name}_#{property_set}_#{property}_1" 156 | expect(template).to receive(:radio_button).with(object_name, property, 1, expected_options) 157 | proxy.radio_button(property, 1) 158 | end 159 | end 160 | end 161 | 162 | describe "#select" do 163 | before do 164 | settings = double("Fake property", count: "2") 165 | allow(object).to receive(property_set).and_return(settings) 166 | end 167 | 168 | it "render a